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();
|
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);
|
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
|
2026-02-12 23:32:08 +00:00
|
|
|
ref={searchInputRef as React.Ref<HTMLInputElement>}
|
2026-02-05 20:16:19 +00:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|