Files
sendou.ink/app/components/elements/Select.tsx
Kalle 4f81f2f3ce
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Fix more jank
2026-09-16 22:10:12 +03:00

936 lines
25 KiB
TypeScript

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<SelectKey>;
matches: (textValue: string) => boolean;
}
const SelectRegistryContext = React.createContext<SelectRegistry | null>(null);
const SelectStateContext = React.createContext<SelectState | null>(null);
/** Inside the trigger an item renders only its content, standing in for the selection. */
const SelectTriggerContext = React.createContext(false);
export interface SendouSelectProps<T extends object> {
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<HTMLButtonElement>;
items?: Iterable<T>;
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
* <SendouSelect items={items} search={{ placeholder: "Search for items..." }}>
* {({ key, ...item }) => (
* <SendouSelectItem key={key} {...item}>
* {item.name}
* </SendouSelectItem>
* )}
* </SendouSelect>
* ```
*/
export function SendouSelect<T extends object>({
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<T>) {
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<SelectKey | null>(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<SelectKey, RegisteredItem>());
const keyByElementRef = React.useRef(new WeakMap<HTMLElement, SelectKey>());
/** 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<SelectKey | null>(null);
const normalizedTextCacheRef = React.useRef(new Map<string, string>());
const typeaheadRef = React.useRef({ query: "", at: 0 });
const triggerElementRef = React.useRef<HTMLButtonElement | null>(null);
const popoverRef = React.useRef<HTMLDivElement | null>(null);
const listboxRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(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<SelectRegistry>(() => ({
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<HTMLElement>(
'[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<HTMLDivElement>) => {
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 = (
<SelectTriggerContext value>{element}</SelectTriggerContext>
);
}
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
<div
className={clsx(className, styles.select)}
data-testid={testId}
onBlur={(event) => {
if (event.currentTarget.contains(event.relatedTarget)) return;
if (focusLeftTo(event, [event.currentTarget])) {
setOpen(false);
}
onBlur?.();
}}
>
{label ? (
<label className={styles.label} id={labelId} htmlFor={triggerId}>
{label}
{labelRequired ? <span className="text-error"> *</span> : null}
</label>
) : null}
{hiddenAriaLabelId ? (
<span id={hiddenAriaLabelId} hidden>
{ariaLabel}
</span>
) : null}
<button
type="button"
id={triggerId}
className={styles.button}
ref={(element) => {
triggerElementRef.current = element;
if (typeof triggerRef === "function") {
triggerRef(element);
} else if (triggerRef) {
triggerRef.current = element;
}
}}
disabled={isDisabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={label ? undefined : ariaLabel}
aria-labelledby={
labelId
? hiddenAriaLabelId
? `${labelId} ${hiddenAriaLabelId} ${valueId}`
: `${valueId} ${labelId}`
: undefined
}
data-required={isRequired || undefined}
popoverTarget={popoverId}
style={{ anchorName } as React.CSSProperties}
onKeyDown={onTriggerKeyDown}
>
<span
id={valueId}
className={styles.selectValue}
data-placeholder={selectedContent === undefined ? "true" : undefined}
>
{selectedContent !== undefined ? selectedContent : placeholder}
</span>
<span aria-hidden="true">
<ChevronsUpDown className={styles.icon} />
</span>
</button>
{clearable && currentKey !== null ? (
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<X />}
onClick={() => commitSelection(null)}
className={styles.clearButton}
>
Clear
</SendouButton>
) : null}
{name ? (
<input type="hidden" name={name} value={currentKey ?? ""} />
) : null}
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown steers the listbox inside */}
<div
ref={popoverRef}
id={popoverId}
popover="auto"
className={clsx(styles.popover, popoverClassName)}
style={{ positionAnchor: anchorName } as React.CSSProperties}
onBeforeToggle={onPopoverBeforeToggle}
onToggle={onPopoverToggle}
onKeyDown={onPopoverKeyDown}
tabIndex={-1}
>
{search && open ? (
<SearchField
registry={registry}
inputRef={searchInputRef}
value={searchValue}
onChange={setSearchValue}
placeholder={search.placeholder}
listboxId={listboxId}
testId={search.testId}
className={clsx(
search.inputClassName ?? "in-container",
styles.searchInput,
)}
/>
) : null}
<Listbox
registry={registry}
open={open}
listboxRef={listboxRef}
id={listboxId}
labelId={labelId}
>
<SelectRegistryContext value={registry}>
<SelectStateContext value={state}>
{renderedChildren}
</SelectStateContext>
</SelectRegistryContext>
{open && visibleItemCount === 0 ? (
<div className={styles.noResults}>{t("common:noResults")}</div>
) : null}
</Listbox>
</div>
</div>
);
}
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<HTMLInputElement | null>;
value: string;
onChange: (value: string) => void;
placeholder?: string;
listboxId: string;
testId?: string;
className: string;
}) {
const activeDescendant = useActiveDescendant(registry, true);
return (
<div
className={styles.searchField}
data-empty={value === "" ? "true" : undefined}
>
<Search aria-hidden className={styles.icon} />
<input
type="search"
ref={inputRef}
value={value}
onChange={(event) => 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}
/>
<button
type="button"
className={styles.searchClearButton}
tabIndex={-1}
aria-label="Clear"
onClick={() => {
onChange("");
inputRef.current?.focus();
}}
>
<X className={styles.icon} />
</button>
</div>
);
}
function Listbox({
registry,
open,
listboxRef,
id,
labelId,
children,
}: {
registry: SelectRegistry;
open: boolean;
listboxRef: React.RefObject<HTMLDivElement | null>;
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
<div
ref={listboxRef}
id={id}
className={clsx(styles.listBox, "scrollbar")}
role="listbox"
tabIndex={-1}
aria-labelledby={open ? labelId : undefined}
aria-activedescendant={activeDescendant}
>
{children}
</div>
);
}
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 <SelectOption {...props} />;
}
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<HTMLDivElement | null>(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 (
<div
ref={elementRef}
id={registry.optionIdFor(id)}
role="option"
tabIndex={-1}
aria-selected={isSelected}
aria-disabled={isDisabled || undefined}
data-disabled={isDisabled ? "true" : undefined}
data-testid={testId}
data-status={dataStatus}
className={clsx(styles.item, className, {
[styles.itemFocused]: isFocused,
[styles.itemSelected]: isSelected,
})}
onClick={() => {
if (!isDisabled) registry.select(id);
}}
onPointerMove={() => {
if (!isDisabled) registry.focus.set(id);
}}
>
{children}
</div>
);
}
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
<div role="group" aria-label={heading}>
<div className={clsx(className, styles.categoryHeading)}>
{headingImgPath ? (
<Image path={headingImgPath} size={28} alt="" />
) : null}
{heading}
<div className={styles.categoryDivider} />
</div>
{children}
</div>
);
}