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
@@ -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";