АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: канон составных detail-menu и customButton-trigger

This commit is contained in:
DCCONSTRUCTIONS
2026-04-22 12:36:23 +03:00
parent c86fc16cdf
commit 082948a69c
7 changed files with 203 additions and 103 deletions
@@ -16,19 +16,42 @@ import { cn } from "../utils";
import type { TContextMenuItem } from "./context-menu";
export type TActionDropdownTrigger = React.ReactNode;
type TActionDropdownMenuContent = React.ReactNode | ((props: { closeDropdown: () => void }) => React.ReactNode);
export interface IActionDropdownProps {
button?: TActionDropdownTrigger;
buttonAsChild?: boolean;
buttonClassName?: string;
className?: string;
disabled?: boolean;
items: TContextMenuItem[];
menuContent?: TActionDropdownMenuContent;
menuClassName?: string;
onOpenChange?: (isOpen: boolean) => void;
placement?: TPlacement;
portalElement?: Element | null;
}
const assignRef = <T,>(ref: React.Ref<T> | undefined, value: T) => {
if (!ref) return;
if (typeof ref === "function") {
ref(value);
return;
}
(ref as React.MutableRefObject<T>).current = value;
};
const composeEventHandlers = <E extends { defaultPrevented?: boolean }>(
...handlers: Array<((event: E) => void) | undefined>
) => {
return (event: E) => {
for (const handler of handlers) {
handler?.(event);
if (event.defaultPrevented) break;
}
};
};
const renderActionContent = (item: TContextMenuItem) =>
item.customContent ?? (
<div className="flex items-start gap-2">
@@ -108,14 +131,27 @@ function ActionDropdownItem(props: TActionDropdownItemProps) {
}
export function ActionDropdown(props: IActionDropdownProps) {
const { button, buttonClassName, className, disabled = false, items, menuClassName, onOpenChange, placement, portalElement } = props;
const {
button,
buttonAsChild = false,
buttonClassName,
className,
disabled = false,
items,
menuContent,
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 isDropdownDisabled = disabled || renderedItems.length === 0;
const hasMenuContent = menuContent !== undefined && menuContent !== null;
const isDropdownDisabled = disabled || (!hasMenuContent && renderedItems.length === 0);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-end",
@@ -172,9 +208,13 @@ export function ActionDropdown(props: IActionDropdownProps) {
useOutsideClickDetector(dropdownRef, closeDropdown);
const setReferenceNode = React.useCallback((node: HTMLElement | null) => {
setReferenceElement(node);
}, []);
const defaultButton = (
<button
ref={setReferenceElement as React.Ref<HTMLButtonElement>}
ref={setReferenceNode 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",
@@ -196,9 +236,34 @@ export function ActionDropdown(props: IActionDropdownProps) {
</button>
);
const asChildButton =
buttonAsChild && React.isValidElement(button) && button.type !== React.Fragment
? React.cloneElement(button, {
ref: (node: HTMLElement | null) => {
setReferenceNode(node);
assignRef((button as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref, node);
},
className: cn((button.props as { className?: string }).className, buttonClassName),
disabled: isDropdownDisabled || (button.props as { disabled?: boolean }).disabled,
onClick: composeEventHandlers(
(button.props as { onClick?: (event: React.MouseEvent<HTMLElement>) => void }).onClick,
handleTriggerClick
),
onKeyDown: composeEventHandlers(
(button.props as { onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void }).onKeyDown,
handleKeyDown
),
type: (button.props as { type?: string }).type ?? "button",
"aria-haspopup": "menu",
"aria-expanded": isOpen,
"aria-label": (button.props as { ["aria-label"]?: string })["aria-label"] ?? "Work item actions",
"data-action-dropdown-trigger": "true",
})
: null;
const customButton = button ? (
<button
ref={setReferenceElement as React.Ref<HTMLButtonElement>}
ref={setReferenceNode 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",
@@ -236,11 +301,19 @@ export function ActionDropdown(props: IActionDropdownProps) {
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>
{hasMenuContent ? (
typeof menuContent === "function" ? (
menuContent({ closeDropdown })
) : (
menuContent
)
) : (
<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
@@ -249,7 +322,7 @@ export function ActionDropdown(props: IActionDropdownProps) {
return (
<div ref={dropdownRef} className={cn("relative h-full", className)} onKeyDownCapture={handleKeyDown}>
{customButton ?? defaultButton}
{asChildButton ?? customButton ?? defaultButton}
{popup}
</div>
);