veza/apps/web/src/components/ui/select/SelectDropdownContent.tsx

157 lines
4.6 KiB
TypeScript
Raw Normal View History

import * as React from 'react';
import { Input } from '../input';
import { SelectOptionItem } from './SelectOptionItem';
import type { GroupedOptions, SelectOption } from './types';
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) {
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
? `${listboxId}-option-${allOptions[highlightedIndex]?.value}`
: 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();
onSelect(allOptions[highlightedIndex]!.value);
}
break;
case 'Home':
e.preventDefault();
setHighlightedIndex(0);
break;
case 'End':
e.preventDefault();
setHighlightedIndex(allOptions.length - 1);
break;
}
};
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}
aria-activedescendant={highlightedOptionId}
onKeyDown={handleKeyDown}
>
{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()}
onKeyDown={handleKeyDown}
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)}
isHighlighted={
highlightedIndex >= 0 &&
highlightedIndex < allOptions.length &&
allOptions[highlightedIndex]?.value === option.value
}
multiple={multiple}
onSelect={onSelect}
optionId={`${listboxId}-option-${option.value}`}
/>
))}
</div>
)}
{Object.entries(filteredOptions.groups).map(([groupLabel, groupOptions]) => (
<div key={groupLabel} className="py-1">
<div className="px-4 py-1.5 text-xs font-semibold text-muted-foreground uppercase">
{groupLabel}
</div>
{groupOptions.map((option) => (
<SelectOptionItem
key={option.value}
option={option}
isSelected={isSelected(option.value)}
isHighlighted={
highlightedIndex >= 0 &&
highlightedIndex < allOptions.length &&
allOptions[highlightedIndex]?.value === option.value
}
multiple={multiple}
onSelect={onSelect}
optionId={`${listboxId}-option-${option.value}`}
/>
))}
</div>
))}
{!hasOptions && (
<div className="px-4 py-2 text-sm text-muted-foreground text-center">
No options found
</div>
)}
</div>
);
}