Files
pn-new-crm/src/components/shared/inline-phone-field.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

157 lines
4.8 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Loader2, Pencil } from 'lucide-react';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { toastError } from '@/lib/api/toast-error';
import { parsePhone } from '@/lib/i18n/phone';
import type { CountryCode } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
interface InlinePhoneFieldProps {
/** E.164 form ('+442079460958'), null when unset. */
e164: string | null | undefined;
/** ISO-3166-1 alpha-2 the number was parsed against. */
country: string | null | undefined;
/** Falls back to this country if `country` isn't set. */
defaultCountry?: CountryCode;
onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>;
/**
* Notifies the parent when the field enters/exits edit mode. Lets the row
* dim or hide noise (tag chips, action buttons) while the user is focused
* on the editor.
*/
onEditingChange?: (editing: boolean) => void;
emptyText?: string;
disabled?: boolean;
className?: string;
'data-testid'?: string;
}
export function InlinePhoneField({
e164,
country,
defaultCountry,
onSave,
onEditingChange,
emptyText = '-',
disabled,
className,
'data-testid': testId,
}: InlinePhoneFieldProps) {
const [editing, setEditingRaw] = useState(false);
const [draft, setDraft] = useState<PhoneInputValue | null>(() => {
if (!e164 && !country) return null;
return {
e164: e164 ?? null,
country: (country as CountryCode | null) ?? defaultCountry ?? 'US',
};
});
const [saving, setSaving] = useState(false);
function setEditing(next: boolean) {
setEditingRaw(next);
onEditingChange?.(next);
}
async function commit() {
const next = draft ?? { e164: null, country: defaultCountry ?? 'US' };
if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) {
setEditing(false);
return;
}
setSaving(true);
try {
await onSave(next);
setEditing(false);
} catch (err) {
toastError(err);
} finally {
setSaving(false);
}
}
if (editing) {
return (
// Two clean lines: country picker + number on top, action pair below.
<div className={cn('flex w-full flex-col gap-2.5', className)}>
<PhoneInput
value={draft}
onChange={(v) => setDraft(v)}
defaultCountry={defaultCountry}
data-testid={testId}
/>
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => {
setDraft(
e164 || country
? {
e164: e164 ?? null,
country: (country as CountryCode | null) ?? defaultCountry ?? 'US',
}
: null,
);
setEditing(false);
}}
disabled={saving}
className={cn(
'inline-flex h-8 items-center rounded-md px-3 text-xs font-medium',
'text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
'disabled:opacity-50',
)}
>
Cancel
</button>
<button
type="button"
onClick={() => void commit()}
disabled={saving}
className={cn(
'inline-flex h-8 min-w-[64px] items-center justify-center rounded-md px-3',
'bg-primary text-xs font-semibold text-primary-foreground shadow-sm',
'transition-colors hover:bg-primary/90 disabled:opacity-50',
)}
>
{saving ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : 'Save'}
</button>
</div>
</div>
);
}
// Display: prefer the parsed national format (more readable than raw E.164).
let display: string | null = null;
if (e164) {
const parsed = parsePhone(e164, (country as CountryCode | undefined) ?? defaultCountry);
display = parsed.national ?? e164;
}
return (
<button
type="button"
disabled={disabled}
onClick={() => setEditing(true)}
data-testid={testId}
className={cn(
'group inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-left text-sm',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}
>
<span className={cn('flex-1', !display && 'text-muted-foreground')}>
{display ?? emptyText}
</span>
{!disabled && (
<Pencil
className="h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60"
aria-hidden
/>
)}
</button>
);
}