import clsx from "clsx"; import { ChevronsUpDown, Search, X } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouButton } from "~/components/elements/Button"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus"; import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; import { focusLeftTo, isOwnToggle, useAnchorSafeId, useShowPopoverOnOpen, } from "./Popover"; import styles from "./Select.module.css"; import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; export type SelectKey = string | number; /** How long a type-ahead query keeps collecting characters, like the native select. */ const TYPEAHEAD_RESET_MS = 1000; interface RegisteredItem { element: HTMLElement; textValue: string; disabled: boolean; getContent: () => React.ReactNode; } /** * Focused option kept outside React state so hovering across options * re-renders only the two options whose focus changed, not the whole list. */ interface FocusStore { get: () => SelectKey | null; set: (key: SelectKey | null) => void; subscribe: (listener: () => void) => () => void; } /** Stable-identity callbacks items use to wire themselves to the select. */ interface SelectRegistry { registerItem: (key: SelectKey, item: RegisteredItem) => () => void; select: (key: SelectKey) => void; focus: FocusStore; optionIdFor: (key: SelectKey) => string; } interface SelectState { open: boolean; selectedKey: SelectKey | null; disabledKeys?: Set; matches: (textValue: string) => boolean; } const SelectRegistryContext = React.createContext(null); const SelectStateContext = React.createContext(null); /** Inside the trigger an item renders only its content, standing in for the selection. */ const SelectTriggerContext = React.createContext(false); export interface SendouSelectProps { label?: string; /** Renders a required marker next to the label. */ labelRequired?: boolean; placeholder?: string; selectedKey?: SelectKey | null; defaultSelectedKey?: SelectKey | null; onSelectionChange?: (key: SelectKey | null) => void; onOpenChange?: (isOpen: boolean) => void; clearable?: boolean; disabledKeys?: SelectKey[]; isDisabled?: boolean; isRequired?: boolean; /** Submits the selected key with the surrounding form. */ name?: string; "data-testid"?: string; "aria-label"?: string; className?: string; popoverClassName?: string; search?: { placeholder?: string; testId?: string; inputClassName?: string }; /** controlled search input value */ searchInputValue?: string; /** When defined, the caller filters `items` (automatic filtering disabled). */ onSearchInputChange?: (value: string) => void; /** Custom search matcher, replacing the default textValue "contains" filter. */ filter?: (textValue: string, searchValue: string) => boolean; errorText?: string; bottomText?: string; onBlur?: () => void; triggerRef?: React.Ref; items?: Iterable; children: React.ReactNode | ((item: T) => React.ReactNode); } /** * A customizable select component with optional search functionality, * rendered through the native popover API with CSS anchor positioning. * * Options mount only while the popover is open (plus the selected one, so the * trigger can show it); the trigger's content is read straight from the * `SendouSelectItem` element matching the selection, or from the element * keyed by the selection when a wrapper component renders the item, so it is * server rendered. * * @example * ```tsx * * {({ key, ...item }) => ( * * {item.name} * * )} * * ``` */ export function SendouSelect({ label, labelRequired, placeholder, selectedKey, defaultSelectedKey, onSelectionChange, onOpenChange, clearable = false, disabledKeys, isDisabled, isRequired, name, "data-testid": testId, "aria-label": ariaLabel, className, popoverClassName, search, searchInputValue, onSearchInputChange, filter, errorText, bottomText, onBlur, triggerRef, items, children, }: SendouSelectProps) { const { t } = useTranslation(["common"]); const uid = useAnchorSafeId(); const popoverId = `${uid}-select-popover`; const listboxId = `${uid}-select-listbox`; const anchorName = `--select-anchor-${uid}`; const labelId = label ? `${uid}-select-label` : undefined; const valueId = `${uid}-select-value`; const triggerId = `${uid}-select-trigger`; const hiddenAriaLabelId = label && ariaLabel ? `${uid}-select-aria-label` : undefined; const [isControlled] = React.useState(selectedKey !== undefined); const [uncontrolledKey, setUncontrolledKey] = React.useState(defaultSelectedKey ?? null); const currentKey = isControlled ? (selectedKey ?? null) : uncontrolledKey; const isSearchControlled = !!onSearchInputChange; const [uncontrolledSearch, setUncontrolledSearch] = React.useState(""); const searchValue = isSearchControlled ? (searchInputValue ?? "") : uncontrolledSearch; const setSearchValue = (value: string) => { if (isSearchControlled) { onSearchInputChange(value); } else { setUncontrolledSearch(value); } }; const [open, setOpenState] = React.useState(false); const [, rerenderWithRegisteredContent] = React.useReducer( (count: number) => count + 1, 0, ); const itemsMapRef = React.useRef(new Map()); const keyByElementRef = React.useRef(new WeakMap()); /** Persists past unregistration so the trigger can render a filtered-out selection. */ const contentByKeyRef = React.useRef( new Map< SelectKey, { textValue: string; getContent: () => React.ReactNode } >(), ); /** Selection whose content only a wrapped item's registration can provide. */ const unresolvedSelectionRef = React.useRef(null); const normalizedTextCacheRef = React.useRef(new Map()); const typeaheadRef = React.useRef({ query: "", at: 0 }); const triggerElementRef = React.useRef(null); const popoverRef = React.useRef(null); const listboxRef = React.useRef(null); const searchInputRef = React.useRef(null); const onPopoverBeforeToggle = useShowPopoverOnOpen({ popoverRef, open, onOpen: () => setOpen(true), }); useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); useAnchorPositioning({ isOpen: open, popoverRef, getAnchor: () => triggerElementRef.current, matchAnchorWidth: true, constrainHeight: true, }); // after positioning, so the selection scrolls into the space the list ends up with useIsomorphicLayoutEffect(() => { if (!open) return; if (search) { searchInputRef.current?.focus(); } else { listboxRef.current?.focus(); } scrollIntoView(currentKey); }, [open]); const commitSelection = (key: SelectKey | null) => { if (!isControlled) { setUncontrolledKey(key); } onSelectionChange?.(key); setOpen(false); triggerElementRef.current?.focus(); }; function setOpen(next: boolean) { if (!next) { popoverRef.current?.hidePopover(); return; } if (open) return; setOpenState(true); onOpenChange?.(true); focusStore.set(currentKey); } const commitSelectionRef = React.useRef(commitSelection); commitSelectionRef.current = commitSelection; const [registry] = React.useState(() => ({ registerItem: (key, item) => { itemsMapRef.current.set(key, item); keyByElementRef.current.set(item.element, key); contentByKeyRef.current.set(key, { textValue: item.textValue, getContent: item.getContent, }); if (key === unresolvedSelectionRef.current) { rerenderWithRegisteredContent(); } return () => { itemsMapRef.current.delete(key); }; }, select: (key) => commitSelectionRef.current(key), focus: createFocusStore(), optionIdFor: (key) => `${popoverId}-option-${key}`, })); const focusStore = registry.focus; /** Keys of the enabled options in DOM order. */ const orderedKeys = () => { const listbox = listboxRef.current; if (!listbox) return []; const keys: SelectKey[] = []; for (const element of listbox.querySelectorAll( '[role="option"]', )) { const key = keyByElementRef.current.get(element); if (key === undefined || itemsMapRef.current.get(key)?.disabled) { continue; } keys.push(key); } return keys; }; const scrollIntoView = (key: SelectKey | null) => { if (key === null) return; document .getElementById(registry.optionIdFor(key)) ?.scrollIntoView({ block: "nearest" }); }; const moveFocus = (move: FocusMove) => { const keys = orderedKeys(); if (keys.length === 0) return; const focusedKey = focusStore.get(); const currentIndex = focusedKey === null ? -1 : keys.indexOf(focusedKey); const targetKey = keys[rovingFocusIndex(move, currentIndex, keys.length, { wrap: false })]; focusStore.set(targetKey); scrollIntoView(targetKey); }; const onTriggerKeyDown = (event: React.KeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setOpen(true); } }; const onPopoverKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); triggerElementRef.current?.focus(); return; } if (event.key === "Tab") { triggerElementRef.current?.focus(); setOpen(false); return; } if (event.key === "ArrowDown") { event.preventDefault(); moveFocus("next"); return; } if (event.key === "ArrowUp") { event.preventDefault(); moveFocus("previous"); return; } if (event.key === "Home" && !search) { event.preventDefault(); moveFocus("first"); return; } if (event.key === "End" && !search) { event.preventDefault(); moveFocus("last"); return; } if ( !search && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey ) { event.preventDefault(); typeahead(event.key); return; } if (event.key === "Enter") { event.preventDefault(); const focusedKey = focusStore.get(); // registration is what proves the option is still in the list, the // search can have filtered the focused one out from under the user const focusedItem = focusedKey !== null ? itemsMapRef.current.get(focusedKey) : undefined; if (focusedKey !== null && focusedItem && !focusedItem.disabled) { commitSelection(focusedKey); } } }; const onPopoverToggle = (event: React.ToggleEvent) => { if (!isOwnToggle(event) || event.newState === "open" || !open) return; setOpenState(false); onOpenChange?.(false); setSearchValue(""); focusStore.set(null); typeaheadRef.current = { query: "", at: 0 }; }; const normalizedSearchValue = normalizeForSearch(searchValue); const normalizedTextValue = (textValue: string) => { const cache = normalizedTextCacheRef.current; let normalized = cache.get(textValue); if (normalized === undefined) { normalized = normalizeForSearch(textValue); cache.set(textValue, normalized); } return normalized; }; /** Focuses the next option starting with what was typed, like the native select. */ const typeahead = (character: string) => { const now = Date.now(); const previous = typeaheadRef.current; const query = now - previous.at > TYPEAHEAD_RESET_MS ? character : previous.query + character; typeaheadRef.current = { query, at: now }; // repeating a single character cycles through the options starting with it const repeatedCharacter = [...query].every( (queryCharacter) => queryCharacter === query[0], ); const needle = normalizeForSearch(repeatedCharacter ? query[0] : query); const keys = orderedKeys(); const focusedKey = focusStore.get(); const focusedIndex = focusedKey === null ? -1 : keys.indexOf(focusedKey); const startIndex = focusedIndex === -1 ? 0 : focusedIndex + (repeatedCharacter ? 1 : 0); for (let offset = 0; offset < keys.length; offset++) { const key = keys[(startIndex + offset) % keys.length]; const textValue = itemsMapRef.current.get(key)?.textValue; if (textValue && normalizedTextValue(textValue).startsWith(needle)) { focusStore.set(key); scrollIntoView(key); return; } } }; const matches = (textValue: string) => { if (searchValue === "") return true; if (filter) return filter(textValue, searchValue); if (isSearchControlled || !search) return true; return normalizedTextValue(textValue).includes(normalizedSearchValue); }; // while searching, keep an actionable item focused so Enter commits the top // result the moment it appears; deliberately runs after every render since // controlled results can arrive without the search value changing React.useEffect(() => { if (!open || !search || searchValue === "") return; const focusedKey = focusStore.get(); if ( focusedKey !== null && itemsMapRef.current.get(focusedKey)?.disabled === false ) { return; } focusStore.set(orderedKeys()[0] ?? null); }); const state: SelectState = { open, selectedKey: currentKey, disabledKeys: disabledKeys ? new Set(disabledKeys) : undefined, matches, }; const renderedChildren = typeof children === "function" ? Array.from(items ?? []).map(children) : children; let selectedItemContent: React.ReactNode | undefined; let visibleItemCount = 0; forEachItemElement( renderedChildren, (itemProps) => { if (itemProps.id === currentKey) { selectedItemContent = itemProps.children; } if (isItemVisible(state, itemProps.id, resolveTextValue(itemProps))) { visibleItemCount++; } }, (element) => { if (currentKey !== null && element.key === String(currentKey)) { selectedItemContent = ( {element} ); } visibleItemCount++; }, ); const registeredSelection = selectedItemContent === undefined && currentKey !== null ? contentByKeyRef.current.get(currentKey) : undefined; const selectedContent = selectedItemContent !== undefined ? selectedItemContent : registeredSelection ? (registeredSelection.getContent() ?? registeredSelection.textValue) : undefined; unresolvedSelectionRef.current = selectedContent === undefined ? currentKey : null; return ( // biome-ignore lint/a11y/noStaticElementInteractions: only observes focus leaving the select
{ if (event.currentTarget.contains(event.relatedTarget)) return; if (focusLeftTo(event, [event.currentTarget])) { setOpen(false); } onBlur?.(); }} > {label ? ( ) : null} {hiddenAriaLabelId ? ( ) : null} {clearable && currentKey !== null ? ( } onClick={() => commitSelection(null)} className={styles.clearButton} > Clear ) : null} {name ? ( ) : null} {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown steers the listbox inside */}
{search && open ? ( ) : null} {renderedChildren} {open && visibleItemCount === 0 ? (
{t("common:noResults")}
) : null}
); } function createFocusStore(): FocusStore { let focusedKey: SelectKey | null = null; const listeners = new Set<() => void>(); return { get: () => focusedKey, set: (key) => { if (key === focusedKey) return; focusedKey = key; for (const listener of listeners) { listener(); } }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, }; } /** Subscribes only this component to focus changes, keeping the select root out of hover re-renders. */ function useActiveDescendant(registry: SelectRegistry, open: boolean) { const focusedKey = React.useSyncExternalStore( registry.focus.subscribe, registry.focus.get, () => null, ); return open && focusedKey !== null ? registry.optionIdFor(focusedKey) : undefined; } function SearchField({ registry, inputRef, value, onChange, placeholder, listboxId, testId, className, }: { registry: SelectRegistry; inputRef: React.RefObject; value: string; onChange: (value: string) => void; placeholder?: string; listboxId: string; testId?: string; className: string; }) { const activeDescendant = useActiveDescendant(registry, true); return (
onChange(event.target.value)} placeholder={placeholder} autoComplete="off" role="combobox" aria-label="Search" aria-controls={listboxId} aria-expanded aria-autocomplete="list" aria-activedescendant={activeDescendant} data-testid={testId} className={className} />
); } function Listbox({ registry, open, listboxRef, id, labelId, children, }: { registry: SelectRegistry; open: boolean; listboxRef: React.RefObject; id: string; labelId?: string; children: React.ReactNode; }) { const activeDescendant = useActiveDescendant(registry, open); return ( // labelled only while open so a closed select exposes exactly one // element under its label (the trigger) to queries
{children}
); } function normalizeForSearch(value: string) { return value .normalize("NFD") .replace(/\p{Diacritic}/gu, "") .toLowerCase(); } /** Case- and diacritic-insensitive "contains" matcher, the select's default search filter. */ export function searchContains(value: string, substring: string) { return normalizeForSearch(value).includes(normalizeForSearch(substring)); } export interface SendouSelectItemProps { id: SelectKey; textValue?: string; isDisabled?: boolean; className?: string; "data-testid"?: string; "data-status"?: string; children: React.ReactNode; } function resolveTextValue({ textValue, children }: SendouSelectItemProps) { return textValue ?? (typeof children === "string" ? children : ""); } /** Closed selects keep only the selected option mounted, open ones the search matches. */ function isItemVisible(state: SelectState, id: SelectKey, textValue: string) { if (!state.open) return state.selectedKey === id; return state.matches(textValue); } /** * Visits the `SendouSelectItem` elements reachable through arrays, fragments * and sections. Items hidden behind other components are opaque: `onOpaque` * lets callers treat them as present. */ function forEachItemElement( node: React.ReactNode, visit: (props: SendouSelectItemProps) => void, onOpaque: (element: React.ReactElement) => void, ) { if (Array.isArray(node)) { for (const child of node) { forEachItemElement(child, visit, onOpaque); } return; } if (!React.isValidElement(node)) return; if (node.type === SendouSelectItem) { visit(node.props as SendouSelectItemProps); } else if ( node.type === React.Fragment || node.type === SendouSelectItemSection ) { forEachItemElement( (node.props as { children?: React.ReactNode }).children, visit, onOpaque, ); } else { onOpaque(node); } } function hasVisibleItems(state: SelectState, node: React.ReactNode) { let found = false; forEachItemElement( node, (itemProps) => { if (isItemVisible(state, itemProps.id, resolveTextValue(itemProps))) { found = true; } }, () => { found = true; }, ); return found; } export function SendouSelectItem(props: SendouSelectItemProps) { const inTrigger = React.use(SelectTriggerContext); if (inTrigger) return props.children; return ; } function SelectOption(props: SendouSelectItemProps) { const { id, isDisabled: isDisabledProp = false, className, "data-testid": testId, "data-status": dataStatus, children, } = props; const registry = React.use(SelectRegistryContext); const state = React.use(SelectStateContext); if (!registry || !state) { throw new Error("SendouSelectItem must be inside SendouSelect"); } const isDisabled = isDisabledProp || !!state.disabledKeys?.has(id); const resolvedText = resolveTextValue(props); const visible = isItemVisible(state, id, resolvedText); const isFocused = React.useSyncExternalStore( registry.focus.subscribe, () => registry.focus.get() === id, () => false, ); const childrenRef = React.useRef(children); childrenRef.current = children; const elementRef = React.useRef(null); React.useEffect(() => { if (!visible) return; const element = elementRef.current; if (!element) return; return registry.registerItem(id, { element, textValue: resolvedText, disabled: isDisabled, getContent: () => childrenRef.current, }); }, [visible, id, resolvedText, isDisabled, registry]); if (!visible) return null; const isSelected = state.selectedKey === id; return (
{ if (!isDisabled) registry.select(id); }} onPointerMove={() => { if (!isDisabled) registry.focus.set(id); }} > {children}
); } interface SendouSelectItemSectionProps { heading: string; headingImgPath?: string; children: React.ReactNode; className?: string; } export function SendouSelectItemSection({ heading, headingImgPath, children, className, }: SendouSelectItemSectionProps) { const state = React.use(SelectStateContext); if (!state) { throw new Error("SendouSelectItemSection must be inside SendouSelect"); } if (!hasVisibleItems(state, children)) return null; return ( // biome-ignore lint/a11y/useSemanticElements: a fieldset would carry form semantics this listbox section does not have
{headingImgPath ? ( ) : null} {heading}
{children}
); }