UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация крошек, предложений и quick-actions dropdown

This commit is contained in:
DCCONSTRUCTIONS
2026-04-22 12:06:19 +03:00
parent 7252d26f46
commit 9ab555f6cb
68 changed files with 2242 additions and 2105 deletions
@@ -6,7 +6,6 @@
import * as React from "react";
import { useState } from "react";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICustomSearchSelectOption } from "@plane/types";
import { CustomSearchSelect } from "../dropdowns";
import { cn } from "../utils";
@@ -23,6 +22,9 @@ type TBreadcrumbNavigationSearchDropdownProps = {
handleOnClick?: () => void;
disableRootHover?: boolean;
shouldTruncate?: boolean;
openOnLabelClick?: boolean;
rotateChevronWhenLast?: boolean;
showLastChevron?: boolean;
};
export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationSearchDropdownProps) {
@@ -36,9 +38,13 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS
isLast = false,
handleOnClick,
shouldTruncate = false,
openOnLabelClick = false,
rotateChevronWhenLast = true,
showLastChevron = true,
} = props;
// state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const shouldOpenOnItemClick = openOnLabelClick || !handleOnClick;
return (
<CustomSearchSelect
@@ -57,45 +63,46 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS
}}
customButton={
<>
<Tooltip tooltipContent={title} position="bottom">
<button
onClick={(e) => {
if (!isLast) {
e.preventDefault();
e.stopPropagation();
handleOnClick?.();
}
}}
className={cn(
"group flex h-full cursor-pointer items-center gap-2 rounded-sm rounded-r-none px-1.5 py-1 text-13 font-medium text-tertiary",
{
"hover:bg-layer-1 hover:text-primary": !isLast,
}
)}
<div
onClick={(e) => {
if (!isLast && !shouldOpenOnItemClick) {
e.preventDefault();
e.stopPropagation();
handleOnClick?.();
}
}}
title={title}
className={cn(
"group flex h-full cursor-pointer items-center gap-2 rounded-sm rounded-r-none px-1.5 py-1 text-13 font-medium text-tertiary",
{
"hover:bg-layer-1 hover:text-primary": !isLast,
}
)}
>
{shouldTruncate && <div className="flex text-tertiary @4xl:hidden">...</div>}
<div
className={cn("flex gap-2", {
"hidden items-center gap-2 @4xl:flex": shouldTruncate,
})}
>
{shouldTruncate && <div className="flex text-tertiary @4xl:hidden">...</div>}
<div
className={cn("flex gap-2", {
"hidden items-center gap-2 @4xl:flex": shouldTruncate,
})}
>
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{title}</Breadcrumbs.Label>
</div>
</button>
</Tooltip>
<Breadcrumbs.Separator
className={cn("rounded-r-sm", {
"bg-layer-1": isDropdownOpen && !isLast,
"hover:bg-layer-1": !isLast,
})}
containerClassName="p-0"
iconClassName={cn("group-hover:rotate-90 hover:text-primary", {
"text-primary": isDropdownOpen,
"rotate-90": isDropdownOpen || isLast,
})}
showDivider={!isLast}
/>
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
<Breadcrumbs.Label>{title}</Breadcrumbs.Label>
</div>
</div>
{(!isLast || showLastChevron) && (
<Breadcrumbs.Separator
className={cn("rounded-r-sm", {
"bg-layer-1": isDropdownOpen && !isLast,
"hover:bg-layer-1": !isLast,
})}
containerClassName="p-0"
iconClassName={cn("group-hover:rotate-90 hover:text-primary", {
"text-primary": isDropdownOpen,
"rotate-90": isDropdownOpen || (isLast && rotateChevronWhenLast),
})}
showDivider={!isLast}
/>
)}
</>
}
disabled={navigationDisabled}
@@ -24,6 +24,14 @@ export const ControlLink = React.forwardRef(function ControlLink(
const LEFT_CLICK_EVENT_CODE = 0;
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
const interactiveTarget = (event.target as HTMLElement | null)?.closest(
'[data-control-link-ignore="true"],button,[role="button"],input,select,textarea,[contenteditable="true"]'
);
if (interactiveTarget && interactiveTarget !== event.currentTarget) {
event.preventDefault();
return;
}
const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE;
if (!clickCondition) {
event.preventDefault();
@@ -0,0 +1,254 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import { MoreHorizontal } from "lucide-react";
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronRightIcon } from "@plane/propel/icons";
import type { TPlacement } from "@plane/propel/utils/placement";
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import { cn } from "../utils";
import type { TContextMenuItem } from "./context-menu";
export type TActionDropdownTrigger = React.ReactNode;
export interface IActionDropdownProps {
button?: TActionDropdownTrigger;
buttonClassName?: string;
className?: string;
items: TContextMenuItem[];
menuClassName?: string;
onOpenChange?: (isOpen: boolean) => void;
placement?: TPlacement;
portalElement?: Element | null;
}
const renderActionContent = (item: TContextMenuItem) =>
item.customContent ?? (
<div className="flex items-start gap-2">
{item.icon && <item.icon className={cn("mt-0.5 h-3 w-3 shrink-0", item.iconClassName)} />}
<div className="min-w-0 flex-1">
{item.title && <h5 className="text-12 font-medium leading-5">{item.title}</h5>}
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</div>
);
type TActionDropdownItemProps = {
item: TContextMenuItem;
onSelect: () => void;
};
function ActionDropdownItem(props: TActionDropdownItemProps) {
const { item, onSelect } = props;
const nestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) ?? [];
const hasNestedItems = nestedItems.length > 0;
const [isNestedOpen, setIsNestedOpen] = React.useState(false);
if (item.shouldRender === false) return null;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (item.disabled) return;
if (hasNestedItems) {
setIsNestedOpen((prev) => !prev);
return;
}
item.action();
onSelect();
};
return (
<div className="space-y-1">
<button
type="button"
className={cn(
"flex w-full items-start gap-2 rounded-[0.9rem] px-2 py-2 text-left text-secondary select-none outline-none transition-colors",
{
"text-placeholder": item.disabled,
"cursor-not-allowed": item.disabled,
"hover:bg-white/6": !item.disabled,
},
item.className
)}
onClick={handleClick}
disabled={item.disabled}
>
<span className="min-w-0 flex-1">{renderActionContent(item)}</span>
{hasNestedItems && <ChevronRightIcon className={cn("mt-1 h-3.5 w-3.5 shrink-0 transition-transform", { "rotate-90": isNestedOpen })} />}
</button>
{hasNestedItems && isNestedOpen && (
<div className="space-y-1 pl-3">
{nestedItems.map((nestedItem) => (
<ActionDropdownItem key={nestedItem.key} item={nestedItem} onSelect={onSelect} />
))}
</div>
)}
</div>
);
}
export function ActionDropdown(props: IActionDropdownProps) {
const { button, buttonClassName, className, items, menuClassName, onOpenChange, placement, portalElement } = props;
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
const [referenceElement, setReferenceElement] = React.useState<HTMLElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = React.useState(false);
const renderedItems = items.filter((item) => item.shouldRender !== false);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-end",
strategy: "fixed",
modifiers: [
{
name: "offset",
options: {
offset: [0, 8],
},
},
{
name: "flip",
options: {
fallbackPlacements: ["bottom-end", "bottom-start", "top-end", "top-start"],
},
},
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const openDropdown = React.useCallback(() => {
if (renderedItems.length === 0) return;
setIsOpen(true);
onOpenChange?.(true);
}, [onOpenChange, renderedItems.length]);
const closeDropdown = React.useCallback(() => {
if (!isOpen) return;
setIsOpen(false);
onOpenChange?.(false);
}, [isOpen, onOpenChange]);
const handleTriggerClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
if (renderedItems.length === 0) return;
if (isOpen) {
closeDropdown();
return;
}
openDropdown();
};
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const defaultButton = (
<button
ref={setReferenceElement as React.Ref<HTMLButtonElement>}
type="button"
className={cn(
"clickable relative grid place-items-center rounded-sm border-0 bg-transparent p-1 text-secondary shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-primary",
{
"cursor-not-allowed": renderedItems.length === 0,
"cursor-pointer": renderedItems.length > 0,
},
buttonClassName
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
disabled={renderedItems.length === 0}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="Work item actions"
data-action-dropdown-trigger="true"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
);
const customButton = button ? (
<button
ref={setReferenceElement as React.Ref<HTMLButtonElement>}
type="button"
className={cn(
"clickable block h-full rounded-full border-0 bg-transparent p-0 shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
{
"cursor-not-allowed": renderedItems.length === 0,
"cursor-pointer": renderedItems.length > 0,
},
buttonClassName
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
disabled={renderedItems.length === 0}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="Work item actions"
data-action-dropdown-trigger="true"
>
{button}
</button>
) : null;
const popup =
isOpen && typeof document !== "undefined"
? ReactDOM.createPortal(
<div
data-prevent-outside-click
className="fixed z-[220]"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div
className={cn(
"nodedc-glass-modal nodedc-glass-popup-surface my-1 min-w-[13rem] rounded-[1.25rem] border-0 px-2 py-2.5 text-12 shadow-none outline-none",
menuClassName
)}
>
<div className="max-h-[min(85vh,40rem)] space-y-1 overflow-y-auto">
{renderedItems.map((item) => (
<ActionDropdownItem key={item.key} item={item} onSelect={closeDropdown} />
))}
</div>
</div>
</div>,
portalElement ?? document.body
)
: null;
return (
<div ref={dropdownRef} className={cn("relative h-full", className)} onKeyDownCapture={handleKeyDown}>
{customButton ?? defaultButton}
{popup}
</div>
);
}
@@ -86,7 +86,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
useCaptureForOutsideClick = false,
} = props;
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
const [referenceElement, setReferenceElement] = React.useState<Element | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = React.useState(false);
// refs
@@ -95,6 +95,27 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
strategy: "fixed",
modifiers: [
{
name: "offset",
options: {
offset: [0, 8],
},
},
{
name: "flip",
options: {
fallbackPlacements: ["bottom-end", "bottom-start", "top-end", "top-start"],
},
},
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const closeAllSubmenus = React.useCallback(() => {
@@ -134,6 +155,9 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
if (closeOnSelect) closeDropdown();
};
const shouldIgnoreContainerClick = (target: EventTarget | null) =>
!!(target instanceof Element && target.closest('[data-custom-menu-trigger="true"]'));
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
@@ -145,6 +169,23 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
if (menuButtonOnClick) menuButtonOnClick();
};
const customButtonElement =
customButton && React.isValidElement(customButton) && typeof customButton.type === "string"
? React.cloneElement(customButton, {
ref: setReferenceElement,
onClick: (e: React.MouseEvent<Element, MouseEvent>) => {
customButton.props.onClick?.(e);
handleMenuButtonClick(e as React.MouseEvent<HTMLButtonElement, MouseEvent>);
},
className: cn(customButton.props.className, customButtonClassName),
tabIndex: disabled ? -1 : customButton.props.tabIndex ?? customButtonTabIndex,
role: customButton.props.role ?? (customButton.type === "button" ? undefined : "button"),
"aria-label": ariaLabel,
"aria-disabled": disabled || undefined,
"data-custom-menu-trigger": "true",
})
: null;
const handleMouseEnter = () => {
if (openOnHover) openDropdown();
};
@@ -232,6 +273,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (shouldIgnoreContainerClick(e.target)) return;
handleOnClick();
}}
onMouseEnter={handleMouseEnter}
@@ -242,23 +284,27 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
<>
{customButton ? (
<Menu.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
onClick={handleMenuButtonClick}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
disabled={disabled}
aria-label={ariaLabel}
>
{customButton}
</button>
{customButtonElement ?? (
<button
data-custom-menu-trigger="true"
ref={setReferenceElement as React.Ref<HTMLButtonElement>}
type="button"
onClick={handleMenuButtonClick}
className={customButtonClassName}
tabIndex={customButtonTabIndex}
disabled={disabled}
aria-label={ariaLabel}
>
{customButton}
</button>
)}
</Menu.Button>
) : (
<>
{ellipsis || verticalEllipsis ? (
<Menu.Button as={React.Fragment}>
<button
data-custom-menu-trigger="true"
ref={setReferenceElement}
type="button"
onClick={handleMenuButtonClick}
@@ -275,6 +321,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) {
) : (
<Menu.Button as={React.Fragment}>
<button
data-custom-menu-trigger="true"
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 rounded-md px-2.5 py-1 text-11 whitespace-nowrap duration-300 ${
@@ -5,6 +5,7 @@
*/
export * from "./context-menu";
export * from "./action-dropdown";
export * from "./custom-menu";
export * from "./custom-select";
export * from "./custom-search-select";