[PAI-963] feat: enhance CustomSelect component with context for dropdown management (#8202)
* feat: enhance CustomSelect component with context for dropdown management * refactor: streamline CustomSelect component structure and improve dropdown options rendering
This commit is contained in:
parent
af939fca41
commit
7caa1bb482
1 changed files with 96 additions and 80 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import React, { useRef, useState } from "react";
|
import React, { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { usePopper } from "react-popper";
|
import { usePopper } from "react-popper";
|
||||||
import { useOutsideClickDetector } from "@plane/hooks";
|
import { useOutsideClickDetector } from "@plane/hooks";
|
||||||
|
|
@ -13,6 +13,9 @@ import { cn } from "../utils";
|
||||||
// types
|
// types
|
||||||
import type { ICustomSelectItemProps, ICustomSelectProps } from "./helper";
|
import type { ICustomSelectItemProps, ICustomSelectProps } from "./helper";
|
||||||
|
|
||||||
|
// Context to share the close handler with option components
|
||||||
|
const DropdownContext = createContext<() => void>(() => {});
|
||||||
|
|
||||||
function CustomSelect(props: ICustomSelectProps) {
|
function CustomSelect(props: ICustomSelectProps) {
|
||||||
const {
|
const {
|
||||||
customButtonClassName = "",
|
customButtonClassName = "",
|
||||||
|
|
@ -42,99 +45,112 @@ function CustomSelect(props: ICustomSelectProps) {
|
||||||
placement: placement ?? "bottom-start",
|
placement: placement ?? "bottom-start",
|
||||||
});
|
});
|
||||||
|
|
||||||
const openDropdown = () => {
|
const openDropdown = useCallback(() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
if (referenceElement) referenceElement.focus();
|
if (referenceElement) referenceElement.focus();
|
||||||
};
|
}, [referenceElement]);
|
||||||
const closeDropdown = () => setIsOpen(false);
|
|
||||||
|
const closeDropdown = useCallback(() => setIsOpen(false), []);
|
||||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
const toggleDropdown = useCallback(() => {
|
||||||
if (isOpen) closeDropdown();
|
if (isOpen) closeDropdown();
|
||||||
else openDropdown();
|
else openDropdown();
|
||||||
};
|
}, [closeDropdown, isOpen, openDropdown]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<DropdownContext.Provider value={closeDropdown}>
|
||||||
as="div"
|
<Combobox
|
||||||
ref={dropdownRef}
|
as="div"
|
||||||
tabIndex={tabIndex}
|
ref={dropdownRef}
|
||||||
value={value}
|
tabIndex={tabIndex}
|
||||||
onChange={onChange}
|
value={value}
|
||||||
className={cn("relative flex-shrink-0 text-left", className)}
|
onChange={(val) => {
|
||||||
onKeyDown={handleKeyDown}
|
onChange?.(val);
|
||||||
disabled={disabled}
|
closeDropdown();
|
||||||
>
|
}}
|
||||||
<>
|
className={cn("relative flex-shrink-0 text-left", className)}
|
||||||
{customButton ? (
|
onKeyDown={handleKeyDown}
|
||||||
<Combobox.Button as={React.Fragment}>
|
disabled={disabled}
|
||||||
<button
|
>
|
||||||
ref={setReferenceElement}
|
<>
|
||||||
type="button"
|
{customButton ? (
|
||||||
className={`flex items-center justify-between gap-1 text-xs ${
|
<Combobox.Button as={React.Fragment}>
|
||||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
<button
|
||||||
} ${customButtonClassName}`}
|
ref={setReferenceElement}
|
||||||
onClick={toggleDropdown}
|
type="button"
|
||||||
>
|
className={`flex items-center justify-between gap-1 text-xs rounded ${
|
||||||
{customButton}
|
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||||
</button>
|
} ${customButtonClassName}`}
|
||||||
</Combobox.Button>
|
onClick={toggleDropdown}
|
||||||
) : (
|
|
||||||
<Combobox.Button as={React.Fragment}>
|
|
||||||
<button
|
|
||||||
ref={setReferenceElement}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300",
|
|
||||||
{
|
|
||||||
"px-3 py-2 text-sm": input,
|
|
||||||
"px-2 py-1 text-xs": !input,
|
|
||||||
"cursor-not-allowed text-custom-text-200": disabled,
|
|
||||||
"cursor-pointer hover:bg-custom-background-80": !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 static>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-48 whitespace-nowrap z-30",
|
|
||||||
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}
|
{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-[0.5px] border-custom-border-300",
|
||||||
|
{
|
||||||
|
"px-3 py-2 text-sm": input,
|
||||||
|
"px-2 py-1 text-xs": !input,
|
||||||
|
"cursor-not-allowed text-custom-text-200": disabled,
|
||||||
|
"cursor-pointer hover:bg-custom-background-80": !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(
|
||||||
|
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-48 whitespace-nowrap z-30",
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
</Combobox.Options>,
|
||||||
</Combobox.Options>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
</Combobox>
|
||||||
</Combobox>
|
</DropdownContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Option(props: ICustomSelectItemProps) {
|
function Option(props: ICustomSelectItemProps) {
|
||||||
const { children, value, className } = props;
|
const { children, value, className } = props;
|
||||||
|
const closeDropdown = useContext(DropdownContext);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(() => {
|
||||||
|
// Close dropdown for both new and already-selected options.
|
||||||
|
requestAnimationFrame(() => closeDropdown());
|
||||||
|
}, [closeDropdown]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox.Option
|
<Combobox.Option
|
||||||
value={value}
|
value={value}
|
||||||
|
|
@ -149,10 +165,10 @@ function Option(props: ICustomSelectItemProps) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ selected }) => (
|
{({ selected }) => (
|
||||||
<>
|
<div onMouseDown={handleMouseDown} className="flex items-center justify-between gap-2 w-full">
|
||||||
{children}
|
{children}
|
||||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Combobox.Option>
|
</Combobox.Option>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue