base
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import type { ElementType, KeyboardEventHandler, ReactNode, Ref } from "react";
|
||||
import React, { Fragment, forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
as?: ElementType | undefined;
|
||||
ref?: Ref<HTMLElement> | undefined;
|
||||
tabIndex?: number | undefined;
|
||||
className?: string | undefined;
|
||||
value?: string | string[] | null;
|
||||
onChange?: (value: any) => void;
|
||||
disabled?: boolean | undefined;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLDivElement> | undefined;
|
||||
multiple?: boolean;
|
||||
renderByDefault?: boolean;
|
||||
button: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ComboDropDown = forwardRef(function ComboDropDown(props: Props, ref) {
|
||||
const { button, renderByDefault = true, children, ...rest } = props;
|
||||
|
||||
const dropDownButtonRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [shouldRender, setShouldRender] = useState(renderByDefault);
|
||||
|
||||
const onHover = () => {
|
||||
setShouldRender(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = dropDownButtonRef.current as any;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
element.addEventListener("mouseenter", onHover);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener("mouseenter", onHover);
|
||||
};
|
||||
}, [dropDownButtonRef, shouldRender]);
|
||||
|
||||
if (!shouldRender) {
|
||||
return (
|
||||
<div ref={dropDownButtonRef} className="flex h-full items-center">
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
<Combobox {...rest} ref={ref}>
|
||||
<Combobox.Button as={Fragment}>{button}</Combobox.Button>
|
||||
{children}
|
||||
</Combobox>
|
||||
);
|
||||
});
|
||||
|
||||
const ComboOptions = Combobox.Options;
|
||||
const ComboOption = Combobox.Option;
|
||||
const ComboInput = Combobox.Input;
|
||||
|
||||
ComboDropDown.displayName = "ComboDropDown";
|
||||
|
||||
export { ComboDropDown, ComboOptions, ComboOption, ComboInput };
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./item";
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ChevronRightIcon } from "@plane/propel/icons";
|
||||
// helpers
|
||||
import { cn } from "../../utils";
|
||||
// types
|
||||
import type { TContextMenuItem } from "./root";
|
||||
import { ContextMenuContext, Portal } from "./root";
|
||||
|
||||
type ContextMenuItemProps = {
|
||||
handleActiveItem: () => void;
|
||||
handleClose: () => void;
|
||||
isActive: boolean;
|
||||
item: TContextMenuItem;
|
||||
};
|
||||
|
||||
export function ContextMenuItem(props: ContextMenuItemProps) {
|
||||
const { handleActiveItem, handleClose, isActive, item } = props;
|
||||
|
||||
// Nested menu state
|
||||
const [isNestedOpen, setIsNestedOpen] = useState(false);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [activeNestedIndex, setActiveNestedIndex] = useState<number>(0);
|
||||
const nestedMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const contextMenuContext = useContext(ContextMenuContext);
|
||||
const hasNestedItems = item.nestedMenuItems && item.nestedMenuItems.length > 0;
|
||||
const renderedNestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) || [];
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right-start",
|
||||
strategy: "fixed",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 4],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const closeNestedMenu = React.useCallback(() => {
|
||||
setIsNestedOpen(false);
|
||||
setActiveNestedIndex(0);
|
||||
}, []);
|
||||
|
||||
// Register this nested menu with the main context
|
||||
React.useEffect(() => {
|
||||
if (contextMenuContext && hasNestedItems) {
|
||||
return contextMenuContext.registerSubmenu(closeNestedMenu);
|
||||
}
|
||||
}, [contextMenuContext, hasNestedItems, closeNestedMenu]);
|
||||
|
||||
const handleItemClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasNestedItems) {
|
||||
// Toggle nested menu
|
||||
if (!isNestedOpen && contextMenuContext) {
|
||||
contextMenuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsNestedOpen(!isNestedOpen);
|
||||
} else {
|
||||
// Execute action for regular items
|
||||
item.action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
handleActiveItem();
|
||||
|
||||
if (hasNestedItems) {
|
||||
// Close other submenus and open this one
|
||||
if (contextMenuContext) {
|
||||
contextMenuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsNestedOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNestedItemClick = (nestedItem: TContextMenuItem, e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
nestedItem.action();
|
||||
if (nestedItem.closeOnClick !== false) {
|
||||
handleClose(); // Close the entire context menu
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation for nested items
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isNestedOpen || !hasNestedItems) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveNestedIndex((prev) => (prev + 1) % renderedNestedItems.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveNestedIndex((prev) => (prev - 1 + renderedNestedItems.length) % renderedNestedItems.length);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const nestedItem = renderedNestedItems[activeNestedIndex];
|
||||
if (!nestedItem.disabled) {
|
||||
handleNestedItemClick(nestedItem);
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
closeNestedMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (isNestedOpen && nestedMenuRef.current) {
|
||||
const menuElement = nestedMenuRef.current;
|
||||
menuElement.addEventListener("keydown", handleKeyDown);
|
||||
// Ensure the menu can receive keyboard events
|
||||
menuElement.setAttribute("tabindex", "-1");
|
||||
menuElement.focus();
|
||||
return () => {
|
||||
menuElement.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [isNestedOpen, activeNestedIndex, renderedNestedItems, hasNestedItems, closeNestedMenu]);
|
||||
|
||||
if (item.shouldRender === false) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-1 py-1.5 text-left text-11 text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": isActive,
|
||||
"text-placeholder": item.disabled,
|
||||
},
|
||||
item.className
|
||||
)}
|
||||
onClick={handleItemClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div className="flex-1">
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("whitespace-pre-line text-tertiary", {
|
||||
"text-placeholder": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{hasNestedItems && <ChevronRightIcon className="h-3 w-3 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Nested Menu */}
|
||||
{hasNestedItems && isNestedOpen && (
|
||||
<Portal container={contextMenuContext?.portalContainer}>
|
||||
<div
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
className="fixed z-[35] min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11"
|
||||
data-context-submenu="true"
|
||||
>
|
||||
<div ref={nestedMenuRef} className="vertical-scrollbar scrollbar-sm max-h-72 overflow-y-scroll">
|
||||
{renderedNestedItems.map((nestedItem, index) => (
|
||||
<button
|
||||
key={nestedItem.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-1 py-1.5 text-left text-11 text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": index === activeNestedIndex,
|
||||
"text-placeholder": nestedItem.disabled,
|
||||
},
|
||||
nestedItem.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleNestedItemClick(nestedItem, e);
|
||||
}}
|
||||
onMouseEnter={() => setActiveNestedIndex(index)}
|
||||
disabled={nestedItem.disabled}
|
||||
data-context-submenu="true"
|
||||
>
|
||||
{nestedItem.customContent ?? (
|
||||
<>
|
||||
{nestedItem.icon && <nestedItem.icon className={cn("h-3 w-3", nestedItem.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{nestedItem.title}</h5>
|
||||
{nestedItem.description && (
|
||||
<p
|
||||
className={cn("whitespace-pre-line text-tertiary", {
|
||||
"text-placeholder": nestedItem.disabled,
|
||||
})}
|
||||
>
|
||||
{nestedItem.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
// hooks
|
||||
import { usePlatformOS } from "../../hooks/use-platform-os";
|
||||
// helpers
|
||||
import { cn } from "../../utils";
|
||||
// components
|
||||
import { ContextMenuItem } from "./item";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
key: string;
|
||||
customContent?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.FC<any>;
|
||||
action: () => void;
|
||||
shouldRender?: boolean;
|
||||
closeOnClick?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
nestedMenuItems?: TContextMenuItem[];
|
||||
};
|
||||
|
||||
// Portal component for nested menus
|
||||
interface PortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
}
|
||||
|
||||
export function Portal({ children, container }: PortalProps) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetContainer = container || document.body;
|
||||
return ReactDOM.createPortal(children, targetContainer);
|
||||
}
|
||||
|
||||
// Context for managing nested menus
|
||||
export const ContextMenuContext = React.createContext<{
|
||||
closeAllSubmenus: () => void;
|
||||
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||
portalContainer?: Element | null;
|
||||
} | null>(null);
|
||||
|
||||
type ContextMenuProps = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
items: TContextMenuItem[];
|
||||
portalContainer?: Element | null;
|
||||
};
|
||||
|
||||
function ContextMenuWithoutPortal(props: ContextMenuProps) {
|
||||
const { parentRef, items, portalContainer } = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [activeItemIndex, setActiveItemIndex] = useState<number>(0);
|
||||
// refs
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const submenuClosersRef = useRef<Set<() => void>>(new Set());
|
||||
// derived values
|
||||
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const closeAllSubmenus = React.useCallback(() => {
|
||||
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
|
||||
}, []);
|
||||
|
||||
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
|
||||
submenuClosersRef.current.add(closeSubmenu);
|
||||
return () => {
|
||||
submenuClosersRef.current.delete(closeSubmenu);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
closeAllSubmenus();
|
||||
setIsOpen(false);
|
||||
setActiveItemIndex(0);
|
||||
};
|
||||
|
||||
// calculate position of context menu
|
||||
useEffect(() => {
|
||||
const parentElement = parentRef.current;
|
||||
const contextMenu = contextMenuRef.current;
|
||||
if (!parentElement || !contextMenu) return;
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (isMobile) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const contextMenuWidth = contextMenu.clientWidth;
|
||||
const contextMenuHeight = contextMenu.clientHeight;
|
||||
|
||||
const clickX = e?.pageX || 0;
|
||||
const clickY = e?.pageY || 0;
|
||||
|
||||
// check if there's enough space at the bottom, otherwise show at the top
|
||||
let top = clickY;
|
||||
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
|
||||
|
||||
// check if there's enough space on the right, otherwise show on the left
|
||||
let left = clickX;
|
||||
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
|
||||
|
||||
setPosition({ x: left, y: top });
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const hideContextMenu = (e: KeyboardEvent) => {
|
||||
if (isOpen && e.key === "Escape") handleClose();
|
||||
};
|
||||
|
||||
parentElement.addEventListener("contextmenu", handleContextMenu);
|
||||
window.addEventListener("keydown", hideContextMenu);
|
||||
|
||||
return () => {
|
||||
parentElement.removeEventListener("contextmenu", handleContextMenu);
|
||||
window.removeEventListener("keydown", hideContextMenu);
|
||||
};
|
||||
}, [contextMenuRef, isMobile, isOpen, parentRef, setIsOpen, setPosition]);
|
||||
|
||||
// handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev + 1) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length);
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const item = renderedItems[activeItemIndex];
|
||||
if (!item.disabled) {
|
||||
renderedItems[activeItemIndex].action();
|
||||
if (item.closeOnClick !== false) handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [activeItemIndex, isOpen, renderedItems, setIsOpen]);
|
||||
|
||||
// Custom handler for nested menu portal clicks
|
||||
React.useEffect(() => {
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if the click is on a nested menu element
|
||||
const isNestedMenuClick = target.closest('[data-context-submenu="true"]');
|
||||
const isMainMenuClick = contextMenuRef.current?.contains(target);
|
||||
|
||||
// Also check if the target itself has the data attribute
|
||||
const isNestedMenuElement = target.hasAttribute("data-context-submenu");
|
||||
|
||||
// If it's a nested menu click, main menu click, or nested menu element, don't close
|
||||
if (isNestedMenuClick || isMainMenuClick || isNestedMenuElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If menu is open and it's an outside click, close it
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
// Use capture phase to ensure we handle the event before other handlers
|
||||
document.addEventListener("mousedown", handleDocumentClick, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentClick, true);
|
||||
};
|
||||
}
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed top-0 left-0 z-30 h-screen w-screen cursor-default opacity-0 transition-opacity",
|
||||
{
|
||||
"pointer-events-auto opacity-100": isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="vertical-scrollbar fixed scrollbar-sm max-h-72 min-w-[12rem] overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5"
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
}}
|
||||
data-context-menu="true"
|
||||
>
|
||||
<ContextMenuContext.Provider value={{ closeAllSubmenus, registerSubmenu, portalContainer }}>
|
||||
{renderedItems.map((item, index) => (
|
||||
<ContextMenuItem
|
||||
key={item.key}
|
||||
handleActiveItem={() => setActiveItemIndex(index)}
|
||||
handleClose={handleClose}
|
||||
isActive={index === activeItemIndex}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenu(props: ContextMenuProps) {
|
||||
let contextMenu = <ContextMenuWithoutPortal {...props} />;
|
||||
const portal = document.querySelector("#context-menu-portal");
|
||||
if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal);
|
||||
return contextMenu;
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@plane/propel/icons";
|
||||
// plane helpers
|
||||
// helpers
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
import { cn } from "../utils";
|
||||
// hooks
|
||||
// types
|
||||
import type {
|
||||
ICustomMenuDropdownProps,
|
||||
ICustomMenuItemProps,
|
||||
ICustomSubMenuProps,
|
||||
ICustomSubMenuTriggerProps,
|
||||
ICustomSubMenuContentProps,
|
||||
} from "./helper";
|
||||
|
||||
interface PortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Portal({ children, container, asChild = false }: PortalProps) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetContainer = container || document.body;
|
||||
|
||||
if (asChild) {
|
||||
return ReactDOM.createPortal(children, targetContainer);
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(<div data-radix-portal="">{children}</div>, targetContainer);
|
||||
}
|
||||
|
||||
// Context for main menu to communicate with submenus
|
||||
const MenuContext = React.createContext<{
|
||||
closeAllSubmenus: () => void;
|
||||
registerSubmenu: (closeSubmenu: () => void) => () => void;
|
||||
} | null>(null);
|
||||
|
||||
function CustomMenu(props: ICustomMenuDropdownProps) {
|
||||
const {
|
||||
ariaLabel,
|
||||
buttonClassName = "",
|
||||
customButtonClassName = "",
|
||||
customButtonTabIndex = 0,
|
||||
placement,
|
||||
children,
|
||||
className = "",
|
||||
customButton,
|
||||
disabled = false,
|
||||
ellipsis = false,
|
||||
label,
|
||||
maxHeight = "md",
|
||||
noBorder = false,
|
||||
noChevron = false,
|
||||
optionsClassName = "",
|
||||
menuItemsClassName = "",
|
||||
verticalEllipsis = false,
|
||||
portalElement,
|
||||
menuButtonOnClick,
|
||||
onMenuClose,
|
||||
tabIndex,
|
||||
closeOnSelect,
|
||||
openOnHover = false,
|
||||
useCaptureForOutsideClick = false,
|
||||
} = props;
|
||||
|
||||
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
// refs
|
||||
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const submenuClosersRef = React.useRef<Set<() => void>>(new Set());
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "auto",
|
||||
});
|
||||
|
||||
const closeAllSubmenus = React.useCallback(() => {
|
||||
submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu());
|
||||
}, []);
|
||||
|
||||
const registerSubmenu = React.useCallback((closeSubmenu: () => void) => {
|
||||
submenuClosersRef.current.add(closeSubmenu);
|
||||
return () => {
|
||||
submenuClosersRef.current.delete(closeSubmenu);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openDropdown = () => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const closeDropdown = React.useCallback(() => {
|
||||
if (isOpen) {
|
||||
closeAllSubmenus();
|
||||
onMenuClose?.();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [isOpen, closeAllSubmenus, onMenuClose]);
|
||||
|
||||
const selectActiveItem = () => {
|
||||
const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector(
|
||||
`[data-headlessui-state="active"] button`
|
||||
);
|
||||
activeItem?.click();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem);
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (closeOnSelect) closeDropdown();
|
||||
};
|
||||
|
||||
const handleMenuButtonClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (openOnHover) openDropdown();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (openOnHover && isOpen) {
|
||||
setTimeout(() => {
|
||||
// Only close if menu is still open
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
}, 150); // Small delay to allow moving to submenu
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
|
||||
|
||||
// Custom handler for submenu portal clicks
|
||||
React.useEffect(() => {
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const isSubmenuClick = target.closest('[data-prevent-outside-click="true"]');
|
||||
const isMainMenuClick = dropdownRef.current?.contains(target);
|
||||
|
||||
// If it's a submenu click or main menu click, don't close
|
||||
if (isSubmenuClick || isMainMenuClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If menu is open and it's an outside click, close it
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick);
|
||||
};
|
||||
}
|
||||
}, [isOpen, closeDropdown, useCaptureForOutsideClick]);
|
||||
|
||||
let menuItems = (
|
||||
<Menu.Items
|
||||
data-prevent-outside-click={!!portalElement}
|
||||
className={cn(
|
||||
"fixed z-30 translate-y-0",
|
||||
menuItemsClassName
|
||||
)} /** translate-y-0 is a hack to create new stacking context. Required for safari */
|
||||
static
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 min-w-[12rem] overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11 whitespace-nowrap focus:outline-none",
|
||||
{
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
},
|
||||
optionsClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<MenuContext.Provider value={{ closeAllSubmenus, registerSubmenu }}>{children}</MenuContext.Provider>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
);
|
||||
|
||||
if (portalElement) {
|
||||
menuItems = ReactDOM.createPortal(menuItems, portalElement);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("relative w-min text-left", className)}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleOnClick();
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-main-menu="true"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
{customButton ? (
|
||||
<Menu.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
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
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
onClick={handleMenuButtonClick}
|
||||
disabled={disabled}
|
||||
className={`relative grid place-items-center rounded-sm p-1 text-secondary outline-none hover:text-primary ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-layer-transparent-hover"
|
||||
} ${buttonClassName}`}
|
||||
tabIndex={customButtonTabIndex}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
</Menu.Button>
|
||||
) : (
|
||||
<Menu.Button as={React.Fragment}>
|
||||
<button
|
||||
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 ${
|
||||
open ? "text-primary" : "text-secondary"
|
||||
} ${noBorder ? "" : "shadow-sm border border-strong focus:outline-none"} ${
|
||||
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
|
||||
} ${buttonClassName}`}
|
||||
onClick={handleMenuButtonClick}
|
||||
tabIndex={customButtonTabIndex}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <ChevronDownIcon className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</Menu.Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isOpen && menuItems}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
// SubMenu context for closing submenu from nested items
|
||||
const SubMenuContext = React.createContext<{ closeSubmenu: () => void } | null>(null);
|
||||
|
||||
// Hook to use submenu context
|
||||
const useSubMenu = () => React.useContext(SubMenuContext);
|
||||
|
||||
// SubMenu implementation
|
||||
function SubMenu(props: ICustomSubMenuProps) {
|
||||
const {
|
||||
children,
|
||||
trigger,
|
||||
disabled = false,
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
placement = "right-start",
|
||||
} = props;
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [referenceElement, setReferenceElement] = React.useState<HTMLSpanElement | null>(null);
|
||||
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
|
||||
const submenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const menuContext = React.useContext(MenuContext);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement,
|
||||
strategy: "fixed", // Use fixed positioning to escape overflow constraints
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 4],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const closeSubmenu = React.useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Register this submenu with the main menu context
|
||||
React.useEffect(() => {
|
||||
if (menuContext) {
|
||||
return menuContext.registerSubmenu(closeSubmenu);
|
||||
}
|
||||
}, [menuContext, closeSubmenu]);
|
||||
|
||||
const toggleSubmenu = () => {
|
||||
if (!disabled) {
|
||||
// Close other submenus when opening this one
|
||||
if (!isOpen && menuContext) {
|
||||
menuContext.closeAllSubmenus();
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleSubmenu();
|
||||
};
|
||||
|
||||
// Close submenu when clicking on other menu items
|
||||
React.useEffect(() => {
|
||||
const handleMenuItemClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Check if the click is on a menu item that's not part of this submenu
|
||||
if (target.closest('[role="menuitem"]') && !submenuRef.current?.contains(target)) {
|
||||
closeSubmenu();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleMenuItemClick);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleMenuItemClick);
|
||||
};
|
||||
}, [closeSubmenu]);
|
||||
|
||||
return (
|
||||
<div ref={submenuRef} className={cn("relative", className)}>
|
||||
<span ref={setReferenceElement} className="w-full">
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
{({ active }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center justify-between rounded-sm px-1 py-1.5 text-left text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active && !disabled,
|
||||
"text-placeholder": disabled,
|
||||
"cursor-not-allowed": disabled,
|
||||
}
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="flex-1">{trigger}</span>
|
||||
<ChevronRightIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</span>
|
||||
|
||||
{isOpen && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
className={cn(
|
||||
"fixed z-30 min-w-[12rem] overflow-hidden rounded-md border-[0.5px] border-subtle-1 bg-surface-1 p-1 text-11",
|
||||
contentClassName
|
||||
)}
|
||||
data-prevent-outside-click="true"
|
||||
onMouseEnter={() => {
|
||||
// Notify parent menu that we're hovering over submenu
|
||||
const mainMenuElement = document.querySelector('[data-main-menu="true"]');
|
||||
if (mainMenuElement) {
|
||||
const mouseEnterEvent = new MouseEvent("mouseenter", { bubbles: true });
|
||||
mainMenuElement.dispatchEvent(mouseEnterEvent);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Notify parent menu that we're leaving submenu
|
||||
const mainMenuElement = document.querySelector('[data-main-menu="true"]');
|
||||
if (mainMenuElement) {
|
||||
const mouseLeaveEvent = new MouseEvent("mouseleave", { bubbles: true });
|
||||
mainMenuElement.dispatchEvent(mouseLeaveEvent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SubMenuContext.Provider value={{ closeSubmenu }}>{children}</SubMenuContext.Provider>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props: ICustomMenuItemProps) {
|
||||
const { children, disabled = false, onClick, className } = props;
|
||||
const submenuContext = useSubMenu();
|
||||
|
||||
return (
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
{({ active, close }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full truncate rounded-sm px-1 py-1.5 text-left text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active && !disabled,
|
||||
"text-placeholder": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
close();
|
||||
onClick?.(e);
|
||||
// Close submenu if this item is inside a submenu
|
||||
submenuContext?.closeSubmenu();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SubMenuTrigger(props: ICustomSubMenuTriggerProps) {
|
||||
const { children, disabled = false, className } = props;
|
||||
|
||||
return (
|
||||
<Menu.Item as="div" disabled={disabled}>
|
||||
{({ active }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-sm px-1 py-1.5 text-left text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active && !disabled,
|
||||
"text-placeholder": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
"cursor-not-allowed": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{children}</span>
|
||||
<ChevronRightIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SubMenuContent(props: ICustomSubMenuContentProps) {
|
||||
const { children, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-[15] min-w-[12rem] overflow-hidden rounded-md border border-subtle-1 bg-surface-1 p-1 text-11",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add all components as static properties for external use
|
||||
CustomMenu.Portal = Portal;
|
||||
CustomMenu.MenuItem = MenuItem;
|
||||
CustomMenu.SubMenu = SubMenu;
|
||||
CustomMenu.SubMenuTrigger = SubMenuTrigger;
|
||||
CustomMenu.SubMenuContent = SubMenuContent;
|
||||
|
||||
export { CustomMenu };
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { Info } from "lucide-react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { CheckIcon, SearchIcon, ChevronDownIcon } from "@plane/propel/icons";
|
||||
// plane imports
|
||||
// local imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
import { cn } from "../utils";
|
||||
import type { ICustomSearchSelectProps } from "./helper";
|
||||
|
||||
export function CustomSearchSelect(props: ICustomSearchSelectProps) {
|
||||
const {
|
||||
customButtonClassName = "",
|
||||
buttonClassName = "",
|
||||
className = "",
|
||||
chevronClassName = "",
|
||||
customButton,
|
||||
placement,
|
||||
disabled = false,
|
||||
footerOption,
|
||||
input = false,
|
||||
label,
|
||||
maxHeight = "md",
|
||||
multiple = false,
|
||||
noChevron = false,
|
||||
onChange,
|
||||
options,
|
||||
onOpen,
|
||||
onClose,
|
||||
optionsClassName = "",
|
||||
value,
|
||||
tabIndex,
|
||||
noResultsMessage = "No matches found",
|
||||
defaultOpen = false,
|
||||
} = props;
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const comboboxProps: any = {
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
const openDropdown = () => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
if (onOpen) onOpen();
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (isOpen) closeDropdown();
|
||||
else openDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("relative flex-shrink-0 text-left", className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...comboboxProps}
|
||||
>
|
||||
{({ open }: { open: boolean }) => {
|
||||
if (open && onOpen) onOpen();
|
||||
|
||||
return (
|
||||
<>
|
||||
{customButton ? (
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 text-11",
|
||||
{
|
||||
"cursor-not-allowed text-secondary": disabled,
|
||||
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
|
||||
},
|
||||
customButtonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
) : (
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong",
|
||||
{
|
||||
"px-3 py-2 text-13": input,
|
||||
"px-2 py-1 text-11": !input,
|
||||
"cursor-not-allowed text-secondary": disabled,
|
||||
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
|
||||
},
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && !disabled && (
|
||||
<ChevronDownIcon className={cn("h-3 w-3 flex-shrink-0", chevronClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
)}
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click static>
|
||||
<div
|
||||
className={cn(
|
||||
"z-30 my-1 min-w-48 overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 py-2.5 text-11 whitespace-nowrap focus:outline-none",
|
||||
optionsClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="mx-2 flex items-center gap-1.5 rounded-sm border border-subtle px-2">
|
||||
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn("vertical-scrollbar mt-2 scrollbar-xs space-y-1 overflow-y-scroll px-2", {
|
||||
"max-h-96": maxHeight === "2xl",
|
||||
"max-h-80": maxHeight === "xl",
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
})}
|
||||
>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active }) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active,
|
||||
"cursor-not-allowed text-placeholder opacity-60": option.disabled,
|
||||
}
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!multiple) closeDropdown();
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
{option.tooltip && (
|
||||
<>
|
||||
{typeof option.tooltip === "string" ? (
|
||||
<Tooltip tooltipContent={option.tooltip}>
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0 cursor-pointer text-secondary" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
option.tooltip
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{noResultsMessage}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
{footerOption}
|
||||
</div>
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Combobox } from "@headlessui/react";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons";
|
||||
// plane helpers
|
||||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
// helpers
|
||||
import { cn } from "../utils";
|
||||
// types
|
||||
import type { ICustomSelectItemProps, ICustomSelectProps } from "./helper";
|
||||
|
||||
// Context to share the close handler with option components
|
||||
const DropdownContext = createContext<() => void>(() => {});
|
||||
|
||||
function CustomSelect(props: ICustomSelectProps) {
|
||||
const {
|
||||
customButtonClassName = "",
|
||||
buttonClassName = "",
|
||||
placement,
|
||||
children,
|
||||
className = "",
|
||||
customButton,
|
||||
disabled = false,
|
||||
input = false,
|
||||
label,
|
||||
maxHeight = "md",
|
||||
noChevron = false,
|
||||
onChange,
|
||||
optionsClassName = "",
|
||||
value,
|
||||
tabIndex,
|
||||
} = props;
|
||||
// states
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
});
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
}, [referenceElement]);
|
||||
|
||||
const closeDropdown = useCallback(() => setIsOpen(false), []);
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
const toggleDropdown = useCallback(() => {
|
||||
if (isOpen) closeDropdown();
|
||||
else openDropdown();
|
||||
}, [closeDropdown, isOpen, openDropdown]);
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider value={closeDropdown}>
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange?.(val);
|
||||
closeDropdown();
|
||||
}}
|
||||
className={cn("relative flex-shrink-0 text-left", className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{customButton ? (
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={`flex items-center justify-between gap-1 rounded text-11 ${
|
||||
disabled ? "cursor-not-allowed text-secondary" : "cursor-pointer hover:bg-layer-transparent-hover"
|
||||
} ${customButtonClassName}`}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
) : (
|
||||
<Combobox.Button as={React.Fragment}>
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 rounded border border-strong",
|
||||
{
|
||||
"px-3 py-2 text-13": input,
|
||||
"px-2 py-1 text-11": !input,
|
||||
"cursor-not-allowed text-secondary": disabled,
|
||||
"cursor-pointer hover:bg-layer-transparent-hover": !disabled,
|
||||
},
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && !disabled && <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
)}
|
||||
</>
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click>
|
||||
<div
|
||||
className={cn(
|
||||
"z-30 my-1 min-w-48 overflow-y-scroll rounded-md border-[0.5px] border-subtle-1 bg-surface-1 px-2 py-2.5 text-11 whitespace-nowrap focus:outline-none",
|
||||
optionsClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div
|
||||
className={cn("space-y-1 overflow-y-scroll", {
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</Combobox>
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Option(props: ICustomSelectItemProps) {
|
||||
const { children, value, className } = props;
|
||||
const closeDropdown = useContext(DropdownContext);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// Close dropdown for both new and already-selected options.
|
||||
// Use setTimeout to ensure HeadlessUI's onChange handler fires first for new selections.
|
||||
// For already-selected options, this ensures the dropdown closes since onChange won't fire.
|
||||
setTimeout(() => {
|
||||
closeDropdown();
|
||||
}, 0);
|
||||
}, [closeDropdown]);
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
value={value}
|
||||
className={({ active }) =>
|
||||
cn(
|
||||
"flex cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 text-secondary select-none",
|
||||
{
|
||||
"bg-layer-transparent-hover": active,
|
||||
},
|
||||
className
|
||||
)
|
||||
}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
{children}
|
||||
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
}
|
||||
|
||||
CustomSelect.Option = Option;
|
||||
|
||||
export { CustomSelect };
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// FIXME: fix this!!!
|
||||
import type { ICustomSearchSelectOption } from "@plane/types";
|
||||
|
||||
type Placement =
|
||||
| "top"
|
||||
| "top-start"
|
||||
| "top-end"
|
||||
| "bottom"
|
||||
| "bottom-start"
|
||||
| "bottom-end"
|
||||
| "left"
|
||||
| "left-start"
|
||||
| "left-end"
|
||||
| "right"
|
||||
| "right-start"
|
||||
| "right-end";
|
||||
|
||||
export interface IDropdownProps {
|
||||
customButtonClassName?: string;
|
||||
customButtonTabIndex?: number;
|
||||
buttonClassName?: string;
|
||||
className?: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
input?: boolean;
|
||||
label?: string | React.ReactNode;
|
||||
maxHeight?: "sm" | "rg" | "md" | "lg" | "xl" | "2xl";
|
||||
noChevron?: boolean;
|
||||
chevronClassName?: string;
|
||||
onOpen?: () => void;
|
||||
optionsClassName?: string;
|
||||
placement?: Placement;
|
||||
tabIndex?: number;
|
||||
useCaptureForOutsideClick?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface IPortalProps {
|
||||
children: React.ReactNode;
|
||||
container?: Element | null;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||
children: React.ReactNode;
|
||||
ellipsis?: boolean;
|
||||
noBorder?: boolean;
|
||||
verticalEllipsis?: boolean;
|
||||
menuButtonOnClick?: (...args: any) => void;
|
||||
menuItemsClassName?: string;
|
||||
onMenuClose?: () => void;
|
||||
closeOnSelect?: boolean;
|
||||
portalElement?: Element | null;
|
||||
openOnHover?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export interface ICustomSelectProps extends IDropdownProps {
|
||||
children: React.ReactNode;
|
||||
value: any;
|
||||
onChange: any;
|
||||
}
|
||||
|
||||
interface CustomSearchSelectProps {
|
||||
footerOption?: React.ReactNode;
|
||||
onChange: any;
|
||||
onClose?: () => void;
|
||||
noResultsMessage?: string;
|
||||
options?: ICustomSearchSelectOption[];
|
||||
}
|
||||
|
||||
interface SingleValueProps {
|
||||
multiple?: false;
|
||||
value: any;
|
||||
}
|
||||
|
||||
interface MultipleValuesProps {
|
||||
multiple?: true;
|
||||
value: any[] | null;
|
||||
}
|
||||
|
||||
export type ICustomSearchSelectProps = IDropdownProps &
|
||||
CustomSearchSelectProps &
|
||||
(SingleValueProps | MultipleValuesProps);
|
||||
|
||||
export interface ICustomMenuItemProps {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: (args?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ICustomSelectItemProps {
|
||||
children: React.ReactNode;
|
||||
value: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Submenu interfaces
|
||||
export interface ICustomSubMenuProps {
|
||||
children: React.ReactNode;
|
||||
trigger: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
export interface ICustomSubMenuTriggerProps {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ICustomSubMenuContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
placement?: Placement;
|
||||
sideOffset?: number;
|
||||
alignOffset?: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./context-menu";
|
||||
export * from "./custom-menu";
|
||||
export * from "./custom-select";
|
||||
export * from "./custom-search-select";
|
||||
export * from "./combo-box";
|
||||
Reference in New Issue
Block a user