feat(a11y): improve keyboard and semantic controls in core UI

This commit is contained in:
2026-04-03 18:54:04 +02:00
parent 8dd1d1bb7c
commit 9c8cd364b4
2 changed files with 84 additions and 48 deletions

View File

@@ -153,17 +153,41 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
manualDisplayMode ?? (shouldDefaultToPreview ? "preview" : "render"); manualDisplayMode ?? (shouldDefaultToPreview ? "preview" : "render");
const previewNodeWidth = Math.max(240, Math.min(640, Math.round(width ?? 500))); const previewNodeWidth = Math.max(240, Math.min(640, Math.round(width ?? 500)));
const setSliderPercent = useCallback((value: number) => {
setSliderX(Math.max(0, Math.min(100, value)));
}, []);
const handleSliderKeyDown = useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => {
let nextValue: number | null = null;
const step = event.shiftKey ? 10 : 2;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
nextValue = sliderX - step;
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
nextValue = sliderX + step;
} else if (event.key === "Home") {
nextValue = 0;
} else if (event.key === "End") {
nextValue = 100;
}
if (nextValue === null) {
return;
}
event.preventDefault();
event.stopPropagation();
setSliderPercent(nextValue);
}, [setSliderPercent, sliderX]);
const handleMouseDown = useCallback((event: React.MouseEvent) => { const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
const move = (moveEvent: MouseEvent) => { const move = (moveEvent: MouseEvent) => {
if (!containerRef.current) return; if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const x = Math.max( const x = Math.max(0, Math.min(1, (moveEvent.clientX - rect.left) / rect.width));
0, setSliderPercent(x * 100);
Math.min(1, (moveEvent.clientX - rect.left) / rect.width),
);
setSliderX(x * 100);
}; };
const up = () => { const up = () => {
@@ -173,7 +197,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
window.addEventListener("mousemove", move); window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up); window.addEventListener("mouseup", up);
}, []); }, [setSliderPercent]);
const handleTouchStart = useCallback((event: React.TouchEvent) => { const handleTouchStart = useCallback((event: React.TouchEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -183,7 +207,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const touch = moveEvent.touches[0]; const touch = moveEvent.touches[0];
const x = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); const x = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
setSliderX(x * 100); setSliderPercent(x * 100);
}; };
const end = () => { const end = () => {
@@ -193,7 +217,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
window.addEventListener("touchmove", move); window.addEventListener("touchmove", move);
window.addEventListener("touchend", end); window.addEventListener("touchend", end);
}, []); }, [setSliderPercent]);
return ( return (
<BaseNodeWrapper nodeType="compare" selected={selected} className="p-0"> <BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
@@ -289,9 +313,18 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
className="pointer-events-none absolute bottom-0 top-0 z-10 w-0.5 bg-white shadow-md" className="pointer-events-none absolute bottom-0 top-0 z-10 w-0.5 bg-white shadow-md"
style={{ left: `${sliderX}%` }} style={{ left: `${sliderX}%` }}
/> />
<div <button
className="pointer-events-none absolute top-1/2 z-20 -translate-x-1/2 -translate-y-1/2" type="button"
className="nodrag absolute top-1/2 z-20 -translate-x-1/2 -translate-y-1/2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
style={{ left: `${sliderX}%` }} style={{ left: `${sliderX}%` }}
onKeyDown={handleSliderKeyDown}
aria-label="Compare slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(sliderX)}
aria-valuetext={`${Math.round(sliderX)} percent`}
aria-orientation="horizontal"
role="slider"
> >
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-white shadow-lg"> <div className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-white shadow-lg">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -304,7 +337,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
/> />
</svg> </svg>
</div> </div>
</div> </button>
</> </>
)} )}

View File

@@ -14,7 +14,6 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
@@ -128,36 +127,48 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
<> <>
<div <div
className={cn( className={cn(
"group relative flex cursor-pointer items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all", "group relative flex items-center gap-4 rounded-xl border bg-card p-4 text-left shadow-sm shadow-foreground/3 transition-all",
"hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4", "hover:bg-muted/60 hover:shadow-md hover:shadow-foreground/4",
"focus-within:ring-2 focus-within:ring-primary/50",
isEditing && "ring-2 ring-primary/50" isEditing && "ring-2 ring-primary/50"
)} )}
onClick={handleCardClick}
> >
{/* Avatar */} {isEditing ? (
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary"> <div className="flex min-w-0 flex-1 items-center gap-4">
{canvas.name.slice(0, 1).toUpperCase()} <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary">
</div> {canvas.name.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<Input
ref={inputRef}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
disabled={isSaving}
autoFocus
className="h-auto border bg-transparent px-1.5 py-0.5 text-sm font-medium focus-visible:ring-1"
/>
<p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
</div>
</div>
) : (
<button
type="button"
onClick={handleCardClick}
className="flex min-w-0 flex-1 items-center gap-4 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`Canvas ${canvas.name} oeffnen`}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-sm font-semibold text-primary">
{canvas.name.slice(0, 1).toUpperCase()}
</div>
{/* Content */} <div className="min-w-0 flex-1">
<div className="min-w-0 flex-1"> <p className="truncate text-sm font-medium">{canvas.name}</p>
{isEditing ? ( <p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
<Input </div>
ref={inputRef} </button>
value={editName} )}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
disabled={isSaving}
autoFocus
onClick={(e) => e.stopPropagation()}
className="h-auto border bg-transparent px-1.5 py-0.5 text-sm font-medium focus-visible:ring-1"
/>
) : (
<p className="truncate text-sm font-medium">{canvas.name}</p>
)}
<p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
</div>
{/* Actions - positioned to not overlap with content */} {/* Actions - positioned to not overlap with content */}
{!isEditing && ( {!isEditing && (
@@ -169,21 +180,14 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" className="size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
onClick={(e) => e.stopPropagation()}
> >
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
<span className="sr-only">Optionen</span> <span className="sr-only">Optionen</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent align="end">
align="end" <DropdownMenuItem onSelect={handleStartEdit}>
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onSelect={handleStartEdit}
onClick={(e) => e.stopPropagation()}
>
<Pencil className="size-4" /> <Pencil className="size-4" />
Umbenennen Umbenennen
</DropdownMenuItem> </DropdownMenuItem>
@@ -193,7 +197,6 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
onSelect={() => { onSelect={() => {
setDeleteOpen(true); setDeleteOpen(true);
}} }}
onClick={(e) => e.stopPropagation()}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Löschen Löschen