UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация крошек, предложений и quick-actions dropdown
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user