feat: enhance accessibility by adding ARIA attributes and focus styles; improve canvas and button elements
This commit is contained in:
14
view/App.tsx
14
view/App.tsx
@@ -159,25 +159,25 @@ export function App({ app }: AppProps) {
|
|||||||
/>
|
/>
|
||||||
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
|
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
|
||||||
<div className="pointer-events-auto flex items-center gap-2">
|
<div className="pointer-events-auto flex items-center gap-2">
|
||||||
<button type="button" className="border-0 bg-transparent p-0" onClick={openGenerate} title="Open generation activity">
|
<button type="button" className="rounded-full border-0 bg-transparent p-0" onClick={openGenerate} aria-label="Open generation activity" title="Open generation activity">
|
||||||
<GenerationJobStatus generation={generation} compact />
|
<GenerationJobStatus generation={generation} compact />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
|
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker} aria-label="Open image" title="Open image (Cmd/Ctrl+O)">
|
||||||
<FolderOpen size={24} />
|
<FolderOpen size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} title="Open project (Cmd/Ctrl+Shift+O)">
|
<button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} aria-label="Open project" title="Open project (Cmd/Ctrl+Shift+O)">
|
||||||
<FolderSimple size={24} />
|
<FolderSimple size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass()} onClick={project.save} title="Save project (Cmd/Ctrl+S)">
|
<button type="button" className={topBarButtonClass()} onClick={project.save} aria-label="Save project" title="Save project (Cmd/Ctrl+S)">
|
||||||
<FloppyDisk size={24} />
|
<FloppyDisk size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
|
<button type="button" className={topBarButtonClass(generateOpen)} aria-label="Generate" aria-pressed={generateOpen} aria-controls="generate-sheet" onClick={toggleGenerate} title="Generate (G)">
|
||||||
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
|
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => void app.actions.document.exportArtboard(activeArtboard?.id)}>
|
<button type="button" className={topBarButtonClass()} aria-label="Export active artboard as PNG" title="Export active artboard as PNG" disabled={!activeArtboard} onClick={() => void app.actions.document.exportArtboard(activeArtboard?.id)}>
|
||||||
<DownloadSimple size={24} />
|
<DownloadSimple size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={toggleLayers}>
|
<button type="button" className={topBarButtonClass(layersOpen)} aria-label="Layers" aria-pressed={layersOpen} aria-controls="layers-sheet" onClick={toggleLayers} title="Layers (L)">
|
||||||
<Stack size={24} weight={layersOpen ? "fill" : "regular"} />
|
<Stack size={24} weight={layersOpen ? "fill" : "regular"} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,14 @@ export function CanvasViewport({
|
|||||||
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
|
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
|
||||||
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, cursorState.isPanning, hasBrushPreview, !brushHint);
|
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, cursorState.isPanning, hasBrushPreview, !brushHint);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`h-full w-full touch-none ${cursorClass}`}
|
||||||
|
role="img"
|
||||||
|
aria-label="Image editing canvas. Use the tools and layers controls to edit the document."
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CanvasCursorState = {
|
type CanvasCursorState = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type GenerateSheetProps = {
|
|||||||
export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) {
|
export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
id="generate-sheet"
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
aria-label="Generate settings"
|
aria-label="Generate settings"
|
||||||
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
|
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch, doc
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
id="layers-sheet"
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
|
aria-label="Layers"
|
||||||
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
|
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
|
||||||
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
|
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
@@ -380,6 +382,7 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
aria-label="Rename item"
|
||||||
className="min-w-0 flex-1 rounded-full bg-white px-3 py-1.5 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
|
className="min-w-0 flex-1 rounded-full bg-white px-3 py-1.5 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
|
|||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
{editingMask ? (
|
{editingMask ? (
|
||||||
<>
|
<>
|
||||||
<button type="button" className={maskModeButtonClass(tool === "brush")} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>Reveal</button>
|
<button type="button" className={maskModeButtonClass(tool === "brush")} aria-pressed={tool === "brush"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>Reveal</button>
|
||||||
<button type="button" className={maskModeButtonClass(tool === "eraser")} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>Hide</button>
|
<button type="button" className={maskModeButtonClass(tool === "eraser")} aria-pressed={tool === "eraser"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>Hide</button>
|
||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ export function MagicWandControls({ settings, dispatch }: { settings: MagicWandS
|
|||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
<Slider label="Clean" value={settings.despeckle} min={0} max={20} onChange={(despeckle) => dispatch(commandIds.toolSetMagicWandSettings, { despeckle })} />
|
<Slider label="Clean" value={settings.despeckle} min={0} max={20} onChange={(despeckle) => dispatch(commandIds.toolSetMagicWandSettings, { despeckle })} />
|
||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
<button type="button" className={`rounded-full px-4 py-2 text-base font-medium transition ${settings.contiguous ? "bg-white text-black" : "bg-white/10 text-white"}`} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contig</button>
|
<button type="button" className={`rounded-full px-4 py-2 text-base font-medium transition ${settings.contiguous ? "bg-white text-black" : "bg-white/10 text-white"}`} aria-label="Contiguous selection" aria-pressed={settings.contiguous} title="Select only connected pixels" onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contiguous</button>
|
||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
{(["replace", "add", "subtract"] as const).map((mode) => (
|
{(["replace", "add", "subtract"] as const).map((mode) => (
|
||||||
<button key={mode} type="button" className={`rounded-full px-4 py-2 text-base font-medium capitalize transition ${settings.mode === mode ? "bg-white text-black" : "bg-white/10 text-white"}`} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { mode })}>{mode === "subtract" ? "Sub" : mode}</button>
|
<button key={mode} type="button" className={`rounded-full px-4 py-2 text-base font-medium capitalize transition ${settings.mode === mode ? "bg-white text-black" : "bg-white/10 text-white"}`} aria-pressed={settings.mode === mode} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { mode })}>{mode}</button>
|
||||||
))}
|
))}
|
||||||
<span className="px-2 text-sm text-white/60">Shift-click adds, Alt-click subtracts</span>
|
<span className="px-2 text-sm text-white/60">Shift-click adds, Alt-click subtracts</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Check, CaretDown } from "@phosphor-icons/react";
|
import { Check, CaretDown } from "@phosphor-icons/react";
|
||||||
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
|
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
export type BottomControlSelectOption<TValue extends string> = {
|
export type BottomControlSelectOption<TValue extends string> = {
|
||||||
@@ -20,6 +20,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
|||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listboxId = useId();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>();
|
const [menuStyle, setMenuStyle] = useState<CSSProperties>();
|
||||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||||
@@ -50,8 +51,11 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
|||||||
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
|
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
if (event.key === "Escape") setOpen(false);
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointerdown", handlePointerDown);
|
window.addEventListener("pointerdown", handlePointerDown);
|
||||||
@@ -66,6 +70,21 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
|||||||
};
|
};
|
||||||
}, [open, placement]);
|
}, [open, placement]);
|
||||||
|
|
||||||
|
const openAndFocusOption = (index: number) => {
|
||||||
|
setOpen(true);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const optionButtons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
|
||||||
|
optionButtons?.[Math.max(0, Math.min(index, options.length - 1))]?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
|
||||||
|
openAndFocusOption(event.key === "End" ? options.length - 1 : event.key === "Home" ? 0 : selectedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className={`relative min-w-0 ${placement === "inline" ? "w-full" : ""}`}>
|
<div ref={rootRef} className={`relative min-w-0 ${placement === "inline" ? "w-full" : ""}`}>
|
||||||
<button
|
<button
|
||||||
@@ -75,6 +94,8 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
|||||||
aria-label={props["aria-label"]}
|
aria-label={props["aria-label"]}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
onKeyDown={handleTriggerKeyDown}
|
||||||
onClick={() => setOpen((current) => !current)}
|
onClick={() => setOpen((current) => !current)}
|
||||||
>
|
>
|
||||||
{placement === "inline" && label ? <span className="shrink-0 pr-3 text-sm font-medium text-white/55">{label}</span> : null}
|
{placement === "inline" && label ? <span className="shrink-0 pr-3 text-sm font-medium text-white/55">{label}</span> : null}
|
||||||
@@ -86,6 +107,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
|||||||
{open && placement === "inline" ? (
|
{open && placement === "inline" ? (
|
||||||
<SelectOptions
|
<SelectOptions
|
||||||
menuRef={menuRef}
|
menuRef={menuRef}
|
||||||
|
id={listboxId}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
@@ -97,6 +119,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
|||||||
createPortal(
|
createPortal(
|
||||||
<SelectOptions
|
<SelectOptions
|
||||||
menuRef={menuRef}
|
menuRef={menuRef}
|
||||||
|
id={listboxId}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
@@ -114,6 +137,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
|||||||
|
|
||||||
type SelectOptionsProps<TValue extends string> = {
|
type SelectOptionsProps<TValue extends string> = {
|
||||||
menuRef: RefObject<HTMLDivElement | null>;
|
menuRef: RefObject<HTMLDivElement | null>;
|
||||||
|
id: string;
|
||||||
options: readonly BottomControlSelectOption<TValue>[];
|
options: readonly BottomControlSelectOption<TValue>[];
|
||||||
value: TValue;
|
value: TValue;
|
||||||
className: string;
|
className: string;
|
||||||
@@ -123,10 +147,28 @@ type SelectOptionsProps<TValue extends string> = {
|
|||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SelectOptions<TValue extends string>({ menuRef, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>) {
|
function SelectOptions<TValue extends string>({ menuRef, id, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>) {
|
||||||
|
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||||
|
const optionButtons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
|
||||||
|
if (!optionButtons) return;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = event.key === "ArrowDown" ? (index + 1) % options.length
|
||||||
|
: event.key === "ArrowUp" ? (index - 1 + options.length) % options.length
|
||||||
|
: event.key === "Home" ? 0
|
||||||
|
: event.key === "End" ? options.length - 1
|
||||||
|
: undefined;
|
||||||
|
if (nextIndex === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
optionButtons[nextIndex]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={menuRef} className={className} style={style} role="listbox" aria-label={props["aria-label"]}>
|
<div ref={menuRef} id={id} className={className} style={style} role="listbox" aria-label={props["aria-label"]}>
|
||||||
{options.map((option) => {
|
{options.map((option, index) => {
|
||||||
const selected = option.value === value;
|
const selected = option.value === value;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -135,6 +177,8 @@ function SelectOptions<TValue extends string>({ menuRef, options, value, classNa
|
|||||||
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
onKeyDown={(event) => handleOptionKeyDown(event, index)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onValueChange(option.value);
|
onValueChange(option.value);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@@ -14,6 +14,22 @@
|
|||||||
body {
|
body {
|
||||||
@apply m-0 min-w-[320px] bg-background text-foreground;
|
@apply m-0 min-w-[320px] bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:where(button, a, input, textarea, select, [tabindex]):focus-visible {
|
||||||
|
outline: 2px solid rgb(125 211 252 / 0.95);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|||||||
Reference in New Issue
Block a user