АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: канонизация 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 = { type Props = {
disabled?: boolean; disabled?: boolean;
menuButton: ReactNode; menuButton: ReactNode | ((props: { open: boolean }) => ReactNode);
menuButtonWrapperClassName?: string; menuButtonWrapperClassName?: string;
options: TSelectionDropdownOption[]; options: TSelectionDropdownOption[];
placement?: Placement; placement?: Placement;

View File

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

View File

@ -9,12 +9,11 @@ import { History } from "lucide-react";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import type { TDescriptionVersion } from "@plane/types"; import type { TDescriptionVersion } from "@plane/types";
import { CustomMenu } from "@plane/ui"; import { Avatar } from "@plane/ui";
import { calculateTimeAgo } from "@plane/utils"; import { calculateTimeAgo, cn, getFileURL } from "@plane/utils";
// hooks // hooks
import { useMember } from "@/hooks/store/use-member"; import { useMember } from "@/hooks/store/use-member";
// local imports import { SelectionDropdown } from "@/components/common/selection-dropdown";
import { DescriptionVersionsDropdownItem } from "./dropdown-item";
import type { TDescriptionVersionEntityInformation } from "./root"; import type { TDescriptionVersionEntityInformation } from "./root";
type Props = { type Props = {
@ -34,13 +33,21 @@ export const DescriptionVersionsDropdown = observer(function DescriptionVersions
const lastUpdatedByUserDisplayName = latestVersion?.owned_by const lastUpdatedByUserDisplayName = latestVersion?.owned_by
? getUserDetails(latestVersion?.owned_by)?.display_name ? getUserDetails(latestVersion?.owned_by)?.display_name
: entityInformation.createdByDisplayName; : entityInformation.createdByDisplayName;
const latestVersionId = latestVersion?.id;
// translation // translation
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<CustomMenu <SelectionDropdown
label={ disabled={disabled}
<div className="flex items-center gap-1 text-tertiary"> 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"> <span className="grid size-4 flex-shrink-0 place-items-center">
<History className="size-3.5" /> <History className="size-3.5" />
</span> </span>
@ -50,18 +57,32 @@ export const DescriptionVersionsDropdown = observer(function DescriptionVersions
{calculateTimeAgo(lastUpdatedAt)} {calculateTimeAgo(lastUpdatedAt)}
</p> </p>
</div> </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; placement?: Placement;
disabled?: boolean; disabled?: boolean;
tabIndex?: number; tabIndex?: number;
menuButton?: React.ReactNode; menuButton?: React.ReactNode | ((props: { open: boolean }) => React.ReactNode);
menuButtonWrapperClassName?: string; menuButtonWrapperClassName?: string;
isFiltersApplied?: boolean; isFiltersApplied?: boolean;
}; };
@ -58,7 +58,7 @@ export function FiltersDropdown(props: Props) {
className={menuButtonWrapperClassName} className={menuButtonWrapperClassName}
disabled={disabled} disabled={disabled}
> >
{menuButton} {typeof menuButton === "function" ? menuButton({ open }) : menuButton}
</button> </button>
) : ( ) : (
<div ref={setReferenceElement}> <div ref={setReferenceElement}>

View File

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

View File

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

View File

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

View File

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