import type { FC, FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react"; import { createContext, useCallback, useContext, useRef, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons"; const SearchIcon: FC<{ className?: string }> = ({ className }) => ; import { FocusScope, useFilter, useFocusManager } from "react-aria"; import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components"; import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components"; import type { ListData } from "react-stately"; import { useListData } from "react-stately"; import { Avatar } from "@/components/base/avatar/avatar"; import type { IconComponentType } from "@/components/base/badges/badge-types"; import { HintText } from "@/components/base/input/hint-text"; import { Label } from "@/components/base/input/label"; import { Popover } from "@/components/base/select/popover"; import { type SelectItemType, sizes } from "@/components/base/select/select"; import { TagCloseX } from "@/components/base/tags/base-components/tag-close-x"; import { useResizeObserver } from "@/hooks/use-resize-observer"; import { cx } from "@/utils/cx"; import { SelectItem } from "./select-item"; interface ComboBoxValueProps extends AriaGroupProps { size: "sm" | "md"; shortcut?: boolean; isDisabled?: boolean; placeholder?: string; shortcutClassName?: string; placeholderIcon?: IconComponentType | null; ref?: RefObject; onFocus?: FocusEventHandler; onPointerEnter?: PointerEventHandler; } const ComboboxContext = createContext<{ size: "sm" | "md"; selectedKeys: Key[]; selectedItems: ListData; onRemove: (keys: Set) => void; onInputChange: (value: string) => void; }>({ size: "sm", selectedKeys: [], selectedItems: {} as ListData, onRemove: () => {}, onInputChange: () => {}, }); interface MultiSelectProps extends Omit, "children" | "items">, RefAttributes { hint?: string; label?: string; tooltip?: string; size?: "sm" | "md"; placeholder?: string; shortcut?: boolean; items?: SelectItemType[]; popoverClassName?: string; shortcutClassName?: string; selectedItems: ListData; placeholderIcon?: IconComponentType | null; children: AriaListBoxProps["children"]; onItemCleared?: (key: Key) => void; onItemInserted?: (key: Key) => void; } export const MultiSelectBase = ({ items, children, size = "sm", selectedItems, onItemCleared, onItemInserted, shortcut, placeholder = "Search", // Omit these props to avoid conflicts with the `Select` component name: _name, className: _className, ...props }: MultiSelectProps) => { const { contains } = useFilter({ sensitivity: "base" }); const selectedKeys = selectedItems.items.map((item) => item.id); const filter = useCallback( (item: SelectItemType, filterText: string) => { return !selectedKeys.includes(item.id) && contains(item.label || item.supportingText || "", filterText); }, [contains, selectedKeys], ); const accessibleList = useListData({ initialItems: items, filter, }); const onRemove = useCallback( (keys: Set) => { const key = keys.values().next().value; if (!key) return; selectedItems.remove(key); onItemCleared?.(key); }, [selectedItems, onItemCleared], ); const onSelectionChange = (id: Key | null) => { if (!id) { return; } const item = accessibleList.getItem(id); if (!item) { return; } if (!selectedKeys.includes(id as string)) { selectedItems.append(item); onItemInserted?.(id); } accessibleList.setFilterText(""); }; const onInputChange = (value: string) => { accessibleList.setFilterText(value); }; const placeholderRef = useRef(null); const [popoverWidth, setPopoverWidth] = useState(""); // Resize observer for popover width const onResize = useCallback(() => { if (!placeholderRef.current) return; let divRect = placeholderRef.current?.getBoundingClientRect(); setPopoverWidth(divRect.width + "px"); }, [placeholderRef, setPopoverWidth]); useResizeObserver({ ref: placeholderRef, onResize: onResize, box: "border-box", }); return ( {(state) => (
{props.label && ( )} {children} {props.hint && {props.hint}}
)}
); }; const InnerMultiSelect = ({ isDisabled, shortcut, shortcutClassName, placeholder }: Omit) => { const focusManager = useFocusManager(); const comboBoxContext = useContext(ComboboxContext); const comboBoxStateContext = useContext(ComboBoxStateContext); const handleInputKeyDown = (event: KeyboardEvent) => { const isCaretAtStart = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0; if (!isCaretAtStart && event.currentTarget.value !== "") { return; } switch (event.key) { case "Backspace": case "ArrowLeft": focusManager?.focusPrevious({ wrap: false, tabbable: false }); break; case "ArrowRight": focusManager?.focusNext({ wrap: false, tabbable: false }); break; } }; // Ensure dropdown opens on click even if input is already focused const handleInputMouseDown = (_event: React.MouseEvent) => { if (comboBoxStateContext && !comboBoxStateContext.isOpen) { comboBoxStateContext.open(); } }; const handleTagKeyDown = (event: KeyboardEvent, value: Key) => { // Do nothing when tab is clicked to move focus from the tag to the input element. if (event.key === "Tab") { return; } event.preventDefault(); const isFirstTag = comboBoxContext?.selectedItems?.items?.[0]?.id === value; switch (event.key) { case " ": case "Enter": case "Backspace": if (isFirstTag) { focusManager?.focusNext({ wrap: false, tabbable: false }); } else { focusManager?.focusPrevious({ wrap: false, tabbable: false }); } comboBoxContext.onRemove(new Set([value])); break; case "ArrowLeft": focusManager?.focusPrevious({ wrap: false, tabbable: false }); break; case "ArrowRight": focusManager?.focusNext({ wrap: false, tabbable: false }); break; case "Escape": comboBoxStateContext?.close(); break; } }; const isSelectionEmpty = comboBoxContext?.selectedItems?.items?.length === 0; return (
{!isSelectionEmpty && comboBoxContext?.selectedItems?.items?.map((value) => (

{value?.label}

handleTagKeyDown(event, value.id)} onPress={() => comboBoxContext.onRemove(new Set([value.id]))} />
))}
{shortcut && ( )}
); }; export const MultiSelectTagsValue = ({ size, shortcut, placeholder, shortcutClassName, placeholderIcon: Icon = SearchIcon, // Omit this prop to avoid invalid HTML attribute warning isDisabled: _isDisabled, ...otherProps }: ComboBoxValueProps) => { return ( cx( "relative flex w-full items-center gap-2 rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset", isDisabled && "cursor-not-allowed bg-disabled_subtle", isFocusWithin && "ring-2 ring-brand", sizes[size].root, ) } > {({ isDisabled }) => ( <> {Icon && } )} ); }; const MultiSelect = MultiSelectBase as typeof MultiSelectBase & { Item: typeof SelectItem; }; MultiSelect.Item = SelectItem; export { MultiSelect as MultiSelect };