veza/apps/web/src/components/ui/select/SelectDropdownContent.tsx
senke 559cfbee3e 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 23:30:22 +02:00

157 lines
4.7 KiB
TypeScript

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();
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>
);
}