feat(a11y): improve keyboard and semantic controls in core UI
This commit is contained in:
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 +127,18 @@ 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 min-w-0 flex-1 items-center gap-4">
|
||||||
<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 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()}
|
{canvas.name.slice(0, 1).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{isEditing ? (
|
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={editName}
|
value={editName}
|
||||||
@@ -150,14 +147,28 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
autoFocus
|
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"
|
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>
|
<p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{canvas.name}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">Canvas</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 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
|
||||||
|
|||||||
Reference in New Issue
Block a user