Files
pn-new-crm/src/components/shared/image-cropper-dialog.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

195 lines
5.5 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 { useCallback, useState } from 'react';
import Cropper, { type Area } from 'react-easy-crop';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { toastError } from '@/lib/api/toast-error';
export interface ImageCropperDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The picked file (raw, pre-crop). Cropper renders an object URL. */
file: File | null;
/** Aspect ratio for the crop frame. 1 = square (avatars), 4 = wide
* banners. Defaults to 1. */
aspect?: number;
/** Output JPEG quality 0-1. Default 0.85. */
outputQuality?: number;
/** Output width in pixels (height derived from aspect). Default 512. */
outputWidth?: number;
/** Async upload handler — receives the cropped Blob. Cropper closes on
* success; toasts on error. */
onUpload: (blob: Blob) => Promise<void>;
/** Dialog title. Default "Crop image". */
title?: string;
}
/**
* Reusable crop-then-upload modal. Renders a draggable/zoomable crop
* frame over the picked file, then writes the cropped pixels to a
* Canvas, exports as JPEG, and hands the Blob to the caller.
*
* Used for: profile avatars, port logos, brochure covers — anywhere a
* square or fixed-aspect image needs to land in storage.
*/
export function ImageCropperDialog({
open,
onOpenChange,
file,
aspect = 1,
outputQuality = 0.85,
outputWidth = 512,
onUpload,
title = 'Crop image',
}: ImageCropperDialogProps) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pixels, setPixels] = useState<Area | null>(null);
const [uploading, setUploading] = useState(false);
const objectUrl = file ? URL.createObjectURL(file) : null;
const onCropComplete = useCallback((_area: Area, areaPixels: Area) => {
setPixels(areaPixels);
}, []);
async function handleUpload() {
if (!objectUrl || !pixels) return;
setUploading(true);
try {
const blob = await renderCrop(objectUrl, pixels, aspect, outputWidth, outputQuality);
await onUpload(blob);
onOpenChange(false);
} catch (err) {
toastError(err);
} finally {
setUploading(false);
if (objectUrl) URL.revokeObjectURL(objectUrl);
}
}
function handleClose(o: boolean) {
if (!o && objectUrl) URL.revokeObjectURL(objectUrl);
onOpenChange(o);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{objectUrl && (
<>
<div className="relative h-72 w-full bg-muted rounded-md overflow-hidden">
<Cropper
image={objectUrl}
crop={crop}
zoom={zoom}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
cropShape={aspect === 1 ? 'round' : 'rect'}
showGrid={false}
/>
</div>
<div className="space-y-2 pt-2">
<Label htmlFor="cropper-zoom" className="text-xs text-muted-foreground">
Zoom
</Label>
<input
id="cropper-zoom"
type="range"
min={1}
max={3}
step={0.05}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="w-full"
/>
</div>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={uploading}>
Cancel
</Button>
<Button onClick={handleUpload} disabled={uploading || !pixels}>
{uploading ? (
<>
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
Uploading
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Crop the source image at `pixels`, resize to `outputWidth`×
* (outputWidth/aspect), and return as a JPEG Blob. Async because
* <img> needs an onload event before we can drawImage.
*/
async function renderCrop(
src: string,
pixels: Area,
aspect: number,
outputWidth: number,
quality: number,
): Promise<Blob> {
const image = await loadImage(src);
const canvas = document.createElement('canvas');
const outputHeight = Math.round(outputWidth / aspect);
canvas.width = outputWidth;
canvas.height = outputHeight;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2d context unavailable');
ctx.drawImage(
image,
pixels.x,
pixels.y,
pixels.width,
pixels.height,
0,
0,
outputWidth,
outputHeight,
);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))),
'image/jpeg',
quality,
);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = src;
});
}