Files
pn-new-crm/src/components/shared/client-picker.tsx
Matt c8ea9ec0a0 fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:

- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/

The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.

Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.

Test suite stays at 1315/1315 vitest. typescript clean.

Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00

116 lines
3.5 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface ClientOption {
id: string;
fullName: string;
}
interface ClientPickerProps {
value: string | null;
onChange: (clientId: string | null) => void;
placeholder?: string;
disabled?: boolean;
}
export function ClientPicker({
value,
onChange,
placeholder = 'Select client...',
disabled,
}: ClientPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300);
const { data } = useQuery<{ data: ClientOption[] }>({
queryKey: ['client-picker', debounced],
queryFn: () =>
apiFetch(
`/api/v1/clients?search=${encodeURIComponent(debounced)}&page=1&limit=10&order=desc&includeArchived=false`,
),
enabled: open,
});
const options = data?.data ?? [];
const selectedLabel = (() => {
if (!value) return placeholder;
const match = options.find((o) => o.id === value);
return match?.fullName ?? `Client ${value.slice(0, 8)}`;
})();
return (
// `modal` is required when this picker is rendered inside a Sheet /
// Dialog — without it the CommandInput stays focus-blocked by the
// outer Sheet's focus trap and clicks/typing are silently dropped.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder="Search clients…" value={search} onValueChange={setSearch} />
<CommandList>
<CommandEmpty>No clients found.</CommandEmpty>
<CommandGroup>
{value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
) : null}
{options.map((c) => (
<CommandItem
key={c.id}
value={c.id}
onSelect={() => {
onChange(c.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')}
/>
<span>{c.fullName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}