АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: канонизация labels, editor и ui dropdown-оберток

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 13:20:49 +03:00
parent 5cf2c2130a
commit bc8081c0f1
8 changed files with 124 additions and 159 deletions

View File

@ -21,7 +21,7 @@ export type TSelectionDropdownOption = {
type Props = {
disabled?: boolean;
menuButton: ReactNode;
menuButton: ReactNode | ((props: { open: boolean }) => ReactNode);
menuButtonWrapperClassName?: string;
options: TSelectionDropdownOption[];
placement?: Placement;

View File

@ -8,7 +8,7 @@ import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TDescriptionVersion } from "@plane/types";
import { Avatar, CustomMenu } from "@plane/ui";
import { Avatar } from "@plane/ui";
import { calculateTimeAgo, getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
@ -28,7 +28,7 @@ export const DescriptionVersionsDropdownItem = observer(function DescriptionVers
const { t } = useTranslation();
return (
<CustomMenu.MenuItem key={version.id} className="flex items-center gap-1" onClick={() => onClick(version.id)}>
<button type="button" className="flex items-center gap-1" onClick={() => onClick(version.id)}>
<span className="flex-shrink-0">
<Avatar
name={versionCreator?.display_name ?? t("common.deactivated_user")}
@ -40,6 +40,6 @@ export const DescriptionVersionsDropdownItem = observer(function DescriptionVers
<span className="font-medium">{versionCreator?.display_name ?? t("common.deactivated_user")}</span>
<span>{calculateTimeAgo(version.last_saved_at)}</span>
</p>
</CustomMenu.MenuItem>
</button>
);
});

View File

@ -9,12 +9,11 @@ import { History } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TDescriptionVersion } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { calculateTimeAgo } from "@plane/utils";
import { Avatar } from "@plane/ui";
import { calculateTimeAgo, cn, getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
// local imports
import { DescriptionVersionsDropdownItem } from "./dropdown-item";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import type { TDescriptionVersionEntityInformation } from "./root";
type Props = {
@ -34,13 +33,21 @@ export const DescriptionVersionsDropdown = observer(function DescriptionVersions
const lastUpdatedByUserDisplayName = latestVersion?.owned_by
? getUserDetails(latestVersion?.owned_by)?.display_name
: entityInformation.createdByDisplayName;
const latestVersionId = latestVersion?.id;
// translation
const { t } = useTranslation();
return (
<CustomMenu
label={
<div className="flex items-center gap-1 text-tertiary">
<SelectionDropdown
disabled={disabled}
placement="bottom-end"
title={t("description_versions.previously_edited_by")}
menuButton={({ open }) => (
<div
className={cn("flex items-center gap-1 text-tertiary transition-colors", {
"text-primary": open,
})}
>
<span className="grid size-4 flex-shrink-0 place-items-center">
<History className="size-3.5" />
</span>
@ -50,18 +57,32 @@ export const DescriptionVersionsDropdown = observer(function DescriptionVersions
{calculateTimeAgo(lastUpdatedAt)}
</p>
</div>
)}
menuButtonWrapperClassName="rounded-sm"
options={
versions?.map((version) => {
const versionCreator = version.owned_by ? getUserDetails(version.owned_by) : null;
return {
key: version.id,
isChecked: version.id === latestVersionId,
onClick: () => onVersionClick(version.id),
icon: (
<Avatar
name={versionCreator?.display_name ?? t("common.deactivated_user")}
size="sm"
src={getFileURL(versionCreator?.avatar_url ?? "")}
/>
),
title: (
<p className="flex items-center gap-1.5 text-11 text-secondary">
<span className="font-medium">{versionCreator?.display_name ?? t("common.deactivated_user")}</span>
<span>{calculateTimeAgo(version.last_saved_at)}</span>
</p>
),
};
}) ?? []
}
noBorder
noChevron={disabled}
placement="bottom-end"
optionsClassName="w-[300px]"
disabled={disabled}
closeOnSelect
>
<p className="mb-1 text-11 font-medium text-tertiary">{t("description_versions.previously_edited_by")}</p>
{versions?.map((version) => (
<DescriptionVersionsDropdownItem key={version.id} onClick={onVersionClick} version={version} />
))}
</CustomMenu>
/>
);
});

View File

@ -20,7 +20,7 @@ type Props = {
placement?: Placement;
disabled?: boolean;
tabIndex?: number;
menuButton?: React.ReactNode;
menuButton?: React.ReactNode | ((props: { open: boolean }) => React.ReactNode);
menuButtonWrapperClassName?: string;
isFiltersApplied?: boolean;
};
@ -58,7 +58,7 @@ export function FiltersDropdown(props: Props) {
className={menuButtonWrapperClassName}
disabled={disabled}
>
{menuButton}
{typeof menuButton === "function" ? menuButton({ open }) : menuButton}
</button>
) : (
<div ref={setReferenceElement}>

View File

@ -5,17 +5,17 @@
*/
import type { MutableRefObject } from "react";
import { useRef, useState } from "react";
import { useState } from "react";
import type { LucideIcon } from "lucide-react";
// plane helpers
import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
import type { ISvgIcons } from "@plane/propel/icons";
import { CloseIcon } from "@plane/propel/icons";
// types
import type { IIssueLabel } from "@plane/types";
// ui
import { CustomMenu, DragHandle } from "@plane/ui";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, DragHandle } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// components
@ -52,11 +52,20 @@ export function LabelItemBlock(props: ILabelItemBlock) {
draggable = true,
} = props;
// states
const [isMenuActive, setIsMenuActive] = useState(true);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const [isMenuActive, setIsMenuActive] = useState(false);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const actionMenuItems: TContextMenuItem[] = customMenuItems
.filter(({ isVisible }) => isVisible)
.map(({ onClick, CustomIcon, text, key }) => ({
key,
action: () => onClick(label),
customContent: (
<span className="flex items-center justify-start gap-2">
<CustomIcon className="size-4" />
<span>{text}</span>
</span>
),
}));
return (
<div className="group flex items-center">
@ -74,26 +83,13 @@ export function LabelItemBlock(props: ILabelItemBlock) {
{!disabled && (
<div
ref={actionSectionRef}
className={`absolute right-2.5 flex items-center gap-2 px-4 ${
isMenuActive || isLabelGroup
? "opacity-100"
: "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
} ${isLabelGroup && "-top-0.5"}`}
>
<CustomMenu ellipsis menuButtonOnClick={() => setIsMenuActive(!isMenuActive)} useCaptureForOutsideClick>
{customMenuItems.map(
({ isVisible, onClick, CustomIcon, text, key }) =>
isVisible && (
<CustomMenu.MenuItem key={key} onClick={() => onClick(label)}>
<span className="flex items-center justify-start gap-2">
<CustomIcon className="size-4" />
<span>{text}</span>
</span>
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
<ActionDropdown placement="bottom-end" onOpenChange={setIsMenuActive} items={actionMenuItems} />
{!isLabelGroup && (
<div className="py-0.5">
<button

View File

@ -7,13 +7,13 @@
import React, { useEffect, useState, useCallback } from "react";
import type { EditorRefApi } from "@plane/editor";
// plane imports
import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons";
import { ChevronDownIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// constants
import type { ToolbarMenuItem } from "@/constants/editor";
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS } from "@/constants/editor";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// local imports
import { ColorDropdown } from "./color-dropdown";
@ -86,8 +86,6 @@ export function PageToolbar(props: Props) {
return initialStates;
});
const [isTypographyMenuOpen, setIsTypographyMenuOpen] = useState(false);
const updateActiveStates = useCallback(() => {
const newActiveStates: Record<string, boolean> = {};
Object.values(toolbarItems)
@ -117,52 +115,39 @@ export function PageToolbar(props: Props) {
return (
<div className="animate-in fade-in flex items-center divide-x divide-subtle-1 overflow-x-scroll duration-200">
<CustomMenu
customButton={
<span
className={cn(
"flex h-7 w-24 items-center justify-between gap-2 rounded-sm border-[0.5px] border-strong px-2 text-left text-13 whitespace-nowrap",
{
"bg-layer-1-selected text-primary": isTypographyMenuOpen,
"text-tertiary hover:bg-layer-1-hover": !isTypographyMenuOpen,
}
)}
>
{activeTypography?.name || "Text"}
<ChevronDownIcon className="size-3 shrink-0" />
</span>
}
className="pr-2"
placement="bottom-start"
closeOnSelect
maxHeight="lg"
menuButtonOnClick={() => setIsTypographyMenuOpen((prev) => !prev)}
onMenuClose={() => setIsTypographyMenuOpen(false)}
>
{TYPOGRAPHY_ITEMS.map((item) => (
<CustomMenu.MenuItem
key={item.renderKey}
className={cn("flex items-center justify-between gap-2", {
"bg-layer-transparent-selected text-primary": activeTypography?.itemKey === item.itemKey,
"hover:bg-layer-transparent-hover": !(activeTypography?.itemKey === item.itemKey),
})}
onClick={() => {
<div className="pr-2">
<SelectionDropdown
placement="bottom-start"
menuButton={({ open }) => (
<span
className={cn(
"flex h-7 w-24 items-center justify-between gap-2 rounded-sm border-[0.5px] border-strong px-2 text-left text-13 whitespace-nowrap",
{
"bg-layer-1-selected text-primary": open,
"text-tertiary hover:bg-layer-1-hover": !open,
}
)}
>
{activeTypography?.name || "Text"}
<ChevronDownIcon className="size-3 shrink-0" />
</span>
)}
options={TYPOGRAPHY_ITEMS.map((item) => ({
key: item.renderKey,
isChecked: activeTypography?.itemKey === item.itemKey,
icon: <item.icon className="size-3" />,
title: item.name,
onClick: () => {
if (activeTypography?.itemKey !== item.itemKey) {
editorRef.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}
}}
>
<span className="flex items-center gap-2">
<item.icon className="size-3" />
{item.name}
</span>
{activeTypography?.itemKey === item.itemKey && <CheckIcon className="size-3 shrink-0 text-tertiary" />}
</CustomMenu.MenuItem>
))}
</CustomMenu>
},
}))}
/>
</div>
<div className="shrink-0">
<ColorDropdown
handleColorSelect={(key, color) =>

View File

@ -9,7 +9,7 @@ import * as React from "react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import type { TContextMenuItem } from "../dropdowns";
import { CustomMenu } from "../dropdowns";
import { ActionDropdown } from "../dropdowns";
import { cn } from "../utils";
import { Breadcrumbs } from "./breadcrumbs";
@ -36,7 +36,7 @@ export function BreadcrumbNavigationDropdown(props: TBreadcrumbNavigationDropdow
function NavigationButton() {
return (
<Tooltip tooltipContent={selectedItem?.title} position="bottom" disabled={isOpen}>
<button
<div
onClick={(e) => {
if (!isLast) {
e.preventDefault();
@ -56,7 +56,7 @@ export function BreadcrumbNavigationDropdown(props: TBreadcrumbNavigationDropdow
{selectedItemIcon && <Breadcrumbs.Icon>{selectedItemIcon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{selectedItem?.title}</Breadcrumbs.Label>
</div>
</button>
</div>
</Tooltip>
);
}
@ -66,8 +66,8 @@ export function BreadcrumbNavigationDropdown(props: TBreadcrumbNavigationDropdow
}
return (
<CustomMenu
customButton={
<ActionDropdown
button={
<>
<NavigationButton />
<Breadcrumbs.Separator
@ -86,40 +86,21 @@ export function BreadcrumbNavigationDropdown(props: TBreadcrumbNavigationDropdow
}
placement="bottom-start"
className="h-full rounded-sm"
customButtonClassName={cn(
buttonClassName={cn(
"group flex h-full cursor-pointer items-center gap-0.5 rounded-sm outline-none hover:bg-surface-2",
{
"bg-surface-2": isOpen,
}
)}
closeOnSelect
menuButtonOnClick={() => {
setIsOpen(!isOpen);
}}
onMenuClose={() => {
setIsOpen(false);
}}
>
{navigationItems.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (item.key === selectedItemKey) return;
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
onOpenChange={setIsOpen}
items={navigationItems.map((item) => ({
...item,
action: () => {
if (item.key === selectedItemKey) return;
item.action();
},
customContent: (
<div className="flex w-full items-center gap-2">
{item.icon && <item.icon className={cn("size-4 flex-shrink-0", item.iconClassName)} />}
<div className="w-full">
<h5>{item.title}</h5>
@ -134,9 +115,9 @@ export function BreadcrumbNavigationDropdown(props: TBreadcrumbNavigationDropdow
)}
</div>
{item.key === selectedItemKey && <CheckIcon className="size-3.5 flex-shrink-0" />}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
),
}))}
/>
);
}

View File

@ -5,11 +5,12 @@
*/
import React from "react";
import { MoreVertical } from "lucide-react";
// plane utils
import { calculateTimeAgo, cn, getIconForLink } from "@plane/utils";
// plane ui
import type { TContextMenuItem } from "../dropdowns/context-menu/root";
import { CustomMenu } from "../dropdowns/custom-menu";
import { ActionDropdown } from "../dropdowns/action-dropdown";
export type TLinkItemBlockProps = {
title: string;
@ -38,36 +39,17 @@ export function LinkItemBlock(props: TLinkItemBlockProps) {
</div>
{menuItems && (
<div className="hidden group-hover:block">
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
{menuItems.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn("flex w-full items-center gap-2", {
"text-placeholder": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<ActionDropdown
placement="bottom-end"
menuClassName="z-20"
button={
<span className="grid place-items-center rounded-sm p-1 text-secondary hover:bg-white/6">
<MoreVertical className="h-3.5 w-3.5" />
</span>
}
buttonClassName="grid place-items-center"
items={menuItems}
/>
</div>
)}
</div>