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>
135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { usePathname } from 'next/navigation';
|
||
import { Fragment } from 'react';
|
||
import { ChevronRight } from 'lucide-react';
|
||
|
||
import {
|
||
Breadcrumb,
|
||
BreadcrumbItem,
|
||
BreadcrumbLink,
|
||
BreadcrumbList,
|
||
BreadcrumbPage,
|
||
BreadcrumbSeparator,
|
||
} from '@/components/ui/breadcrumb';
|
||
import { useUIStore } from '@/stores/ui-store';
|
||
import { useBreadcrumbStore } from '@/stores/breadcrumb-store';
|
||
|
||
// Human-readable labels for route segments
|
||
const SEGMENT_LABELS: Record<string, string> = {
|
||
dashboard: 'Dashboard',
|
||
clients: 'Clients',
|
||
interests: 'Interests',
|
||
berths: 'Berths',
|
||
documents: 'Documents',
|
||
files: 'Files',
|
||
expenses: 'Expenses',
|
||
invoices: 'Invoices',
|
||
email: 'Email',
|
||
reminders: 'Reminders',
|
||
settings: 'Settings',
|
||
admin: 'Administration',
|
||
reports: 'Reports',
|
||
new: 'New',
|
||
edit: 'Edit',
|
||
profile: 'Profile',
|
||
};
|
||
|
||
// UUID v4-ish (or any 36-char hex+dash) - used to skip entity-id segments
|
||
// from the breadcrumbs since the page H1 already shows the entity name.
|
||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
|
||
function isIdSegment(segment: string): boolean {
|
||
return UUID_RE.test(segment);
|
||
}
|
||
|
||
function formatSegment(segment: string): string {
|
||
return (
|
||
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||
);
|
||
}
|
||
|
||
export function Breadcrumbs() {
|
||
const pathname = usePathname();
|
||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||
const hint = useBreadcrumbStore((s) => s.hints[pathname]);
|
||
|
||
// Split pathname and filter empty segments
|
||
const rawSegments = pathname.split('/').filter(Boolean);
|
||
|
||
// Remove the portSlug segment and any UUID-ish entity-id segments - the
|
||
// page H1 already shows the entity name, no need to leak the raw id.
|
||
const segments = (
|
||
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
|
||
).filter((seg) => !isIdSegment(seg));
|
||
|
||
if (segments.length === 0) return null;
|
||
|
||
// Build href for each segment from the URL.
|
||
const urlCrumbs = segments.map((segment, index) => {
|
||
const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1);
|
||
const href = '/' + segmentsUpToHere.join('/');
|
||
const label = formatSegment(segment);
|
||
const isLast = index === segments.length - 1;
|
||
|
||
return { label, href, isLast };
|
||
});
|
||
|
||
// When a detail page registered a hint, splice in the parent crumbs
|
||
// (e.g. the parent client name) and replace the trailing label with
|
||
// the entity's actual name (e.g. "B17"). This turns the URL-only
|
||
// "Clients › Interests" into "Clients › Mary Smith › Interest › B17"
|
||
// when the rep clicked from a client page. URL-only renders untouched
|
||
// when no hint is registered.
|
||
const crumbs = (() => {
|
||
if (!hint) return urlCrumbs;
|
||
const head = urlCrumbs.slice(0, -1).map((c) => ({ ...c, isLast: false }));
|
||
const parents = hint.parents.map((p) => ({
|
||
label: p.label,
|
||
href: p.href ?? pathname,
|
||
isLast: false,
|
||
}));
|
||
const lastUrlCrumb = urlCrumbs[urlCrumbs.length - 1];
|
||
const tail = {
|
||
label: hint.current,
|
||
href: lastUrlCrumb?.href ?? pathname,
|
||
isLast: true,
|
||
};
|
||
return [...head, ...parents, tail];
|
||
})();
|
||
|
||
return (
|
||
<Breadcrumb>
|
||
<BreadcrumbList className="text-sm gap-1.5">
|
||
{crumbs.map((crumb, _index) => (
|
||
<Fragment key={crumb.href}>
|
||
<BreadcrumbItem>
|
||
{crumb.isLast ? (
|
||
<BreadcrumbPage className="font-medium text-foreground truncate max-w-[160px]">
|
||
{crumb.label}
|
||
</BreadcrumbPage>
|
||
) : (
|
||
<BreadcrumbLink asChild>
|
||
<Link
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
href={crumb.href as any}
|
||
className="text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 truncate max-w-[160px]"
|
||
>
|
||
{crumb.label}
|
||
</Link>
|
||
</BreadcrumbLink>
|
||
)}
|
||
</BreadcrumbItem>
|
||
{!crumb.isLast && (
|
||
<BreadcrumbSeparator className="text-muted-foreground/40">
|
||
<ChevronRight className="w-3 h-3" aria-hidden />
|
||
</BreadcrumbSeparator>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</BreadcrumbList>
|
||
</Breadcrumb>
|
||
);
|
||
}
|