Files
pn-new-crm/src/components/layout/breadcrumbs.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

135 lines
4.4 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 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>
);
}