Files
pn-new-crm/src/components/shared/country-combobox.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

170 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useMemo, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
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 { cn } from '@/lib/utils';
import { ALL_COUNTRY_CODES, getCountryName, type CountryCode } from '@/lib/i18n/countries';
interface CountryComboboxProps {
value: string | null | undefined;
onChange: (iso: CountryCode | null) => void;
/** Display locale; defaults to navigator.language so country names follow the user. */
locale?: string;
/** When true, renders just the flag/code (compact 24×24 trigger). */
compact?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
/** Allow clearing the selection. */
clearable?: boolean;
id?: string;
'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers so the
* user lands directly in the picker after clicking the edit affordance. */
defaultOpen?: boolean;
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
* this to auto-exit edit mode when the user dismisses without picking. */
onOpenChange?: (open: boolean) => void;
}
/**
* Returns the regional-indicator emoji flag for an ISO alpha-2 code.
* E.g. 'GB' → 🇬🇧. Avoids shipping a flag-image asset and respects the
* platform's emoji rendering (iOS/macOS render real flags; Windows
* shows the country code on a flag rectangle).
*/
function flagEmoji(code: string): string {
if (code.length !== 2) return '';
const A = 0x1f1e6;
const a = 'A'.charCodeAt(0);
const cp1 = A + code.charCodeAt(0) - a;
const cp2 = A + code.charCodeAt(1) - a;
return String.fromCodePoint(cp1, cp2);
}
export function CountryCombobox({
value,
onChange,
locale,
compact = false,
placeholder = 'Select country…',
disabled,
className,
clearable = true,
id,
'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: CountryComboboxProps) {
const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
// Pre-build the options list once per locale change so the cmdk filter
// can search by both code + localized name without re-allocating.
const options = useMemo(() => {
return ALL_COUNTRY_CODES.map((code) => ({
code,
name: getCountryName(code, effectiveLocale),
flag: flagEmoji(code),
})).sort((a, b) => a.name.localeCompare(b.name, effectiveLocale));
}, [effectiveLocale]);
const selected = value ? options.find((o) => o.code === value) : undefined;
return (
// modal: required when this combobox is nested inside a Sheet
// (Radix Dialog). Without it, the parent Dialog's pointer-events
// handling swallows the trigger's tap on iOS Safari — same fix
// pattern as TimezoneCombobox.
<Popover modal open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
'justify-between',
compact ? 'w-20 px-2' : 'w-full',
!selected && 'text-muted-foreground',
className,
)}
data-testid={testId}
>
{selected ? (
<span className="flex min-w-0 items-center gap-2">
<span className="text-base leading-none">{selected.flag}</span>
{!compact ? (
<span className="truncate text-sm">{selected.name}</span>
) : (
<span className="text-xs font-medium">{selected.code}</span>
)}
</span>
) : (
<span className="truncate">{placeholder}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popper-anchor-width) min-w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country or code…" />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
{clearable && value ? (
<CommandGroup>
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
</CommandGroup>
) : null}
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.code}
// cmdk filters by `value` - include both code + name.
value={`${opt.name} ${opt.code}`}
onSelect={() => {
onChange(opt.code);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === opt.code ? 'opacity-100' : 'opacity-0')}
/>
<span className="mr-2 text-base leading-none">{opt.flag}</span>
<span className="flex-1 truncate text-sm">{opt.name}</span>
<span className="text-xs text-muted-foreground">{opt.code}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}