Files
pn-new-crm/src/components/berths/berth-card.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

185 lines
7.0 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 { Activity, MoreHorizontal, Pencil } from 'lucide-react';
import { useRouter, useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { formatCurrency } from '@/lib/utils/currency';
import type { BerthRow } from './berth-columns';
import { mooringLetterDot } from './mooring-letter-tone';
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
interface BerthCardProps {
berth: BerthRow;
}
export function BerthCard({ berth }: BerthCardProps) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
const statusPill = BERTH_STATUS_PILL[berth.status] ?? 'pending';
// Accent stripe groups visually by dock (A-row, B-row, ...). Status is
// already conveyed by the pill below, so the stripe is dock-keyed.
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
// Dimensions string — Length × Width × Draft (each segment is optional).
// The avatar already conveys the mooring number, so this becomes the
// primary "what is this berth" line.
const dimParts: string[] = [];
if (berth.lengthM) dimParts.push(`${berth.lengthM}m`);
if (berth.widthM) dimParts.push(`${berth.widthM}m`);
if (berth.draftM) dimParts.push(`${berth.draftM}m draft`);
const dimText = dimParts.length > 0 ? dimParts.join(' × ') : null;
// Recommended boat size — the most rep-actionable signal in a glance
// ("can my client's yacht park here?"). Tenure was previously here but
// dropped: tenure is set per EOI/contract, not per berth, so showing
// it as a berth property was misleading.
let boatCapacityText: string | null = null;
if (berth.nominalBoatSizeM) {
boatCapacityText = `Fits up to ${berth.nominalBoatSizeM}m`;
} else if (berth.nominalBoatSize) {
boatCapacityText = `Fits up to ${berth.nominalBoatSize}ft`;
}
// Water depth — operational; matters for deep-keel yachts.
let waterDepthText: string | null = null;
if (berth.waterDepthM) {
const prefix = berth.waterDepthIsMinimum ? '≥ ' : '';
waterDepthText = `${prefix}${berth.waterDepthM}m deep`;
}
// Power label: combine capacity + voltage when both present.
let powerText: string | null = null;
if (berth.powerCapacity && berth.voltage) {
powerText = `${berth.powerCapacity}A / ${berth.voltage}V`;
} else if (berth.powerCapacity) {
powerText = `${berth.powerCapacity}A`;
} else if (berth.voltage) {
powerText = `${berth.voltage}V`;
}
// Secondary meta: boat-capacity · water-depth · price · power. All
// optional; order favours the highest-utility scan signals first.
const metaParts: string[] = [];
if (boatCapacityText) metaParts.push(boatCapacityText);
if (waterDepthText) metaParts.push(waterDepthText);
if (berth.price)
metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }));
if (powerText) metaParts.push(powerText);
const tags = berth.tags ?? [];
return (
<ListCard
href={`/${portSlug}/berths/${berth.id}`}
ariaLabel={`Berth ${berth.mooringNumber}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for berth ${berth.mooringNumber}`}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${portSlug}/berths/${berth.id}`);
}}
>
<Activity className="mr-2 h-3.5 w-3.5" aria-hidden />
View details
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${portSlug}/berths/${berth.id}?edit=true`);
}}
>
<Pencil className="mr-2 h-3.5 w-3.5" aria-hidden />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-center gap-3">
{/* The mooring number IS the avatar — recognisable at a glance
(A1, B12, …) and eliminates the duplicate berth-number heading
that previously sat to the right of an anchor icon. */}
<ListCardAvatar
initials={berth.mooringNumber}
className="text-base font-bold tracking-tight"
/>
<div className="min-w-0 flex-1">
{/* Primary line: dimensions (L × W × Draft). The avatar
already carries the area letter, so this slot becomes the
"what fits here" answer. Falls back gracefully when
dimensions aren't recorded yet. */}
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm font-semibold text-foreground">
{dimText ?? <span className="font-normal text-muted-foreground">No dimensions</span>}
</p>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Meta line: tenure · price · power. All optional. */}
{metaParts.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{metaParts.map((part, i) => (
<span key={part} className="inline-flex items-center gap-1">
{i > 0 ? <span aria-hidden>·</span> : null}
<ListCardMeta>{part}</ListCardMeta>
</span>
))}
</div>
) : null}
{/* Status pill + tags */}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 2 ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{tags.length - 2}
</span>
) : null}
</div>
</div>
</div>
</ListCard>
);
}