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

158 lines
4.7 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();
refactor(web): zero out 3 ESLint warning buckets (storybook + react-refresh + non-null-assertion) Three rules cleaned in parallel passes — 187 fewer warnings, 0 TS errors, 0 behaviour change beyond one incidental auth bugfix flagged below. storybook/no-redundant-story-name (23 → 0) — 14 stories files Storybook v7+ infers the story name from the variable name, so `name: 'Default'` next to `export const Default: Story = …` is pure noise. Removed only when the name was redundant ; preserved when the label was a French translation ('Par défaut', 'Chargement', 'Avec erreur', etc.) since those are intentional. react-refresh/only-export-components (25 → 0) — 21 files Each warning marks a file that exports a React component AND a hook / context / constant / barrel re-export. Suppressed per-line with the suppression-with-justification pattern : // eslint-disable-next-line react-refresh/only-export-components -- <kind>; refactor would split a tightly-coupled API The justification matters — every comment names the specific thing being co-located (hook / context / CVA constant / lazy registry / route config / test util / backward-compat barrel). Splitting these would create 21 new files for a HMR-only DX win that's already a non-issue in practice. @typescript-eslint/no-non-null-assertion (139 → 0) — 43 files Distribution of fixes : ~85 cases : refactored to explicit guard `if (!x) throw new Error('invariant: …')` or hoisted into local with narrowing. ~36 cases : helper extraction (one tooltip test had 16 `wrapper!` patterns reduced to a single `getWrapper()` helper). ~18 cases : suppressed with specific reason : static literal arrays where index is provably in bounds, mock fixtures with structural guarantees, filter-then-map patterns where the filter excludes the null branch. One incidental find : services/api/auth.ts threw on missing tokens but didn't guard `user` ; added the missing check while refactoring the `user!` to a guard. baseline post-commit : 921 warnings, 0 errors, 0 TS errors. The remaining buckets are no-restricted-syntax (757, design-system guardrail), no-explicit-any (115), exhaustive-deps (49). CI --max-warnings will be lowered to 921 in the follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:30:22 +00:00
const opt = allOptions[highlightedIndex];
if (opt) onSelect(opt.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 as React.Ref<HTMLInputElement>}
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>
);
}