2026-02-09 22:04:35 +00:00
|
|
|
import * as React from 'react';
|
2026-02-05 20:16:19 +00:00
|
|
|
import { Input } from '../input';
|
|
|
|
|
import { SelectOptionItem } from './SelectOptionItem';
|
2026-02-09 22:04:35 +00:00
|
|
|
import type { GroupedOptions, SelectOption } from './types';
|
2026-02-05 20:16:19 +00:00
|
|
|
|
|
|
|
|
interface SelectDropdownContentProps {
|
|
|
|
|
searchable: boolean;
|
|
|
|
|
search: string;
|
|
|
|
|
onSearchChange: (v: string) => void;
|
|
|
|
|
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
|
|
|
|
filteredOptions: GroupedOptions;
|
|
|
|
|
multiple: boolean;
|
|
|
|
|
isSelected: (value: string) => boolean;
|
|
|
|
|
onSelect: (value: string) => void;
|
|
|
|
|
ariaLabel?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SelectDropdownContent({
|
|
|
|
|
searchable,
|
|
|
|
|
search,
|
|
|
|
|
onSearchChange,
|
|
|
|
|
searchInputRef,
|
|
|
|
|
filteredOptions,
|
|
|
|
|
multiple,
|
|
|
|
|
isSelected,
|
|
|
|
|
onSelect,
|
|
|
|
|
ariaLabel,
|
|
|
|
|
name,
|
|
|
|
|
placeholder,
|
|
|
|
|
}: SelectDropdownContentProps) {
|
2026-02-09 22:04:35 +00:00
|
|
|
const listboxId = React.useId();
|
|
|
|
|
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
|
|
|
|
|
|
|
|
|
|
// Flatten all options for keyboard navigation
|
|
|
|
|
const allOptions = React.useMemo(() => {
|
|
|
|
|
const options: SelectOption[] = [...filteredOptions.ungrouped];
|
|
|
|
|
Object.values(filteredOptions.groups).forEach((groupOptions) => {
|
|
|
|
|
options.push(...groupOptions);
|
|
|
|
|
});
|
|
|
|
|
return options.filter((o) => !o.disabled);
|
|
|
|
|
}, [filteredOptions]);
|
|
|
|
|
|
|
|
|
|
const highlightedOptionId =
|
|
|
|
|
highlightedIndex >= 0 && highlightedIndex < allOptions.length
|
2026-02-12 22:12:35 +00:00
|
|
|
? `${listboxId}-option-${allOptions[highlightedIndex]?.value}`
|
2026-02-09 22:04:35 +00:00
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
|
|
|
switch (e.key) {
|
|
|
|
|
case 'ArrowDown':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setHighlightedIndex((prev) =>
|
|
|
|
|
prev < allOptions.length - 1 ? prev + 1 : 0,
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
case 'ArrowUp':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setHighlightedIndex((prev) =>
|
|
|
|
|
prev > 0 ? prev - 1 : allOptions.length - 1,
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
case 'Enter':
|
|
|
|
|
case ' ':
|
|
|
|
|
if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) {
|
|
|
|
|
e.preventDefault();
|
2026-02-12 22:12:35 +00:00
|
|
|
onSelect(allOptions[highlightedIndex]!.value);
|
2026-02-09 22:04:35 +00:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case 'Home':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setHighlightedIndex(0);
|
|
|
|
|
break;
|
|
|
|
|
case 'End':
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setHighlightedIndex(allOptions.length - 1);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-05 20:16:19 +00:00
|
|
|
const hasOptions =
|
|
|
|
|
filteredOptions.ungrouped.length > 0 ||
|
|
|
|
|
Object.keys(filteredOptions.groups).length > 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="w-full min-w-48 max-h-72 overflow-y-auto"
|
|
|
|
|
role="listbox"
|
|
|
|
|
aria-label={ariaLabel || name || placeholder}
|
2026-02-09 22:04:35 +00:00
|
|
|
aria-activedescendant={highlightedOptionId}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
2026-02-05 20:16:19 +00:00
|
|
|
>
|
|
|
|
|
{searchable && (
|
|
|
|
|
<div className="p-2 border-b">
|
|
|
|
|
<Input
|
|
|
|
|
ref={searchInputRef}
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Search..."
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
2026-02-09 22:04:35 +00:00
|
|
|
onKeyDown={handleKeyDown}
|
2026-02-05 20:16:19 +00:00
|
|
|
className="w-full"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{filteredOptions.ungrouped.length > 0 && (
|
|
|
|
|
<div className="py-1">
|
|
|
|
|
{filteredOptions.ungrouped.map((option) => (
|
|
|
|
|
<SelectOptionItem
|
|
|
|
|
key={option.value}
|
|
|
|
|
option={option}
|
|
|
|
|
isSelected={isSelected(option.value)}
|
2026-02-09 22:04:35 +00:00
|
|
|
isHighlighted={
|
|
|
|
|
highlightedIndex >= 0 &&
|
|
|
|
|
highlightedIndex < allOptions.length &&
|
2026-02-12 22:12:35 +00:00
|
|
|
allOptions[highlightedIndex]?.value === option.value
|
2026-02-09 22:04:35 +00:00
|
|
|
}
|
2026-02-05 20:16:19 +00:00
|
|
|
multiple={multiple}
|
|
|
|
|
onSelect={onSelect}
|
2026-02-09 22:04:35 +00:00
|
|
|
optionId={`${listboxId}-option-${option.value}`}
|
2026-02-05 20:16:19 +00:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{Object.entries(filteredOptions.groups).map(([groupLabel, groupOptions]) => (
|
|
|
|
|
<div key={groupLabel} className="py-1">
|
2026-02-07 14:26:55 +00:00
|
|
|
<div className="px-4 py-1.5 text-xs font-semibold text-muted-foreground uppercase">
|
2026-02-05 20:16:19 +00:00
|
|
|
{groupLabel}
|
|
|
|
|
</div>
|
|
|
|
|
{groupOptions.map((option) => (
|
|
|
|
|
<SelectOptionItem
|
|
|
|
|
key={option.value}
|
|
|
|
|
option={option}
|
|
|
|
|
isSelected={isSelected(option.value)}
|
2026-02-09 22:04:35 +00:00
|
|
|
isHighlighted={
|
|
|
|
|
highlightedIndex >= 0 &&
|
|
|
|
|
highlightedIndex < allOptions.length &&
|
2026-02-12 22:12:35 +00:00
|
|
|
allOptions[highlightedIndex]?.value === option.value
|
2026-02-09 22:04:35 +00:00
|
|
|
}
|
2026-02-05 20:16:19 +00:00
|
|
|
multiple={multiple}
|
|
|
|
|
onSelect={onSelect}
|
2026-02-09 22:04:35 +00:00
|
|
|
optionId={`${listboxId}-option-${option.value}`}
|
2026-02-05 20:16:19 +00:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{!hasOptions && (
|
2026-02-07 14:26:55 +00:00
|
|
|
<div className="px-4 py-2 text-sm text-muted-foreground text-center">
|
2026-02-05 20:16:19 +00:00
|
|
|
No options found
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|