feat: complete TASK-WP4-06 accessibility

This commit is contained in:
suyx
2026-08-03 15:23:17 +08:00
parent d09fda13c5
commit a1b82ecdc4
9 changed files with 428 additions and 30 deletions
+56
View File
@@ -0,0 +1,56 @@
import { useLayoutEffect, useRef, type RefObject } from "react";
const focusableSelector = [
"a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
function focusableElements(dialog: HTMLElement) {
return [...dialog.querySelectorAll<HTMLElement>(focusableSelector)].filter((element) => !element.hidden && element.getClientRects().length > 0);
}
export function useDialogFocus(onClose: () => void): RefObject<HTMLElement | null> {
const dialogRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useLayoutEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return undefined;
const returnTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const initial = dialog.querySelector<HTMLElement>("[data-dialog-initial-focus]") ?? focusableElements(dialog)[0] ?? dialog;
initial.focus();
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onCloseRef.current();
return;
}
if (event.key !== "Tab") return;
const focusable = focusableElements(dialog);
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0]!;
const last = focusable.at(-1)!;
if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (document.activeElement === last || !dialog.contains(document.activeElement))) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
if (returnTarget?.isConnected) returnTarget.focus();
};
}, []);
return dialogRef;
}
+31 -6
View File
@@ -6,6 +6,25 @@
color: #111111;
}
.editor-page-shell :focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
.editor-skip-link {
position: fixed;
z-index: 20;
top: 8px;
left: 8px;
padding: 8px 12px;
border: 2px solid #111111;
background: #ffffff;
color: #111111;
font-weight: 700;
transform: translateY(-160%);
}
.editor-skip-link:focus { transform: translateY(0); }
.editor-toolbar {
display: grid;
grid-template-columns: 40px minmax(160px, 1fr) auto minmax(128px, 160px) auto auto;
@@ -17,6 +36,8 @@
background: #ffffff;
}
.editor-toolbar-controls { display: contents; }
.editor-toolbar button,
.editor-back,
.editor-inspector button {
@@ -54,8 +75,10 @@
gap: 12px;
}
.editor-title strong {
.editor-title h1 {
margin: 0;
overflow: hidden;
font-size: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -228,7 +251,7 @@
}
.editor-canvas { display: block; width: 100%; height: 100%; outline: none; }
.editor-canvas:focus { outline: 3px solid #005fcc; outline-offset: 3px; }
.editor-canvas:focus-visible { outline: 2px solid #005fcc; outline-offset: 2px; }
.editor-candidates {
position: absolute;
@@ -471,10 +494,9 @@
background: #ffffff;
box-shadow: 8px 8px 0 #111111;
}
.editor-export-dialog > header { display: flex; min-height: 82px; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
.editor-export-dialog > header { display: flex; min-height: 82px; align-items: center; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
.editor-export-dialog > header p { margin: 0 0 3px; color: #62625d; font-size: 10px; font-weight: 800; }
.editor-export-dialog > header h2 { margin: 0; font-size: 22px; }
.editor-export-dialog > header button { width: 36px; height: 36px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: 24px/1 Arial, sans-serif; }
.editor-export-section { padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
.editor-export-section h3 { margin: 0 0 10px; font-size: 13px; }
.editor-export-segments { display: grid; grid-template-columns: 1fr 1fr; }
@@ -511,8 +533,11 @@
@media (max-width: 760px) {
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
.editor-toolbar { grid-template-columns: 40px minmax(0, 1fr) auto; min-height: 56px; }
.editor-toolbar > button, .editor-save-status { display: none; }
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
.editor-history-actions { order: 3; }
.editor-save-status { order: 4; flex: 1 1 128px; }
.editor-toolbar-controls > button { display: block; order: 5; }
.editor-layout { grid-template-columns: 1fr; }
.editor-assets-panel, .editor-inspector { border: 0; }
.editor-assets-panel { order: 2; }
+60 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
import { ConflictExportGuard, ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
@@ -12,6 +12,7 @@ import {
} from "./editor-canvas.js";
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
import { EditorStage } from "./editor-stage.js";
import { useDialogFocus } from "./dialog-focus.js";
import { ExportDialog } from "./export-dialog.js";
import {
completePendingTextForExport,
@@ -124,6 +125,11 @@ function saveStatusLabel(status: ProjectSaveStatus) {
return { conflicted: "版本冲突", dirty: "有未保存修改", failed: "保存失败", saved: "已保存", saving: "正在保存" }[status];
}
function EditorDialog(props: { children: ReactNode; className?: string; labelledBy: string; onClose: () => void }) {
const dialogRef = useDialogFocus(props.onClose);
return <div className="editor-dialog-backdrop"><section aria-labelledby={props.labelledBy} aria-modal="true" className={props.className ?? "editor-confirm"} ref={dialogRef} role="dialog" tabIndex={-1}>{props.children}</section></div>;
}
export function EditorPage({ projectId }: { projectId: string }) {
const [session, setSession] = useState<EditorSession>();
const [project, setProject] = useState<EditorProject>();
@@ -154,6 +160,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
const clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
const conflictExportGuardRef = useRef(new ConflictExportGuard());
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
let active = true;
@@ -224,6 +232,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
for (const option of options) void ensureFont(option.fontId, option.url);
}, [activePanel]);
useEffect(() => {
if (candidateMenu) candidateMenuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
}, [candidateMenu]);
useEffect(() => {
if (!canvasState || selectedIds.length !== 1) {
setTextEdit(undefined);
@@ -725,11 +737,38 @@ export function EditorPage({ projectId }: { projectId: string }) {
if (elements.length > 0) setCandidateMenu({ elements, point });
}
function showAllCandidates() {
if (!canvasState) return;
const elements = [...canvasState.elements].sort((left, right) => right.z_index - left.z_index);
if (elements.length > 0) setCandidateMenu({ elements, point: { x: 0.5, y: 0.08 } });
}
function chooseCandidate(elementId: string) {
const controller = controllerForCurrent();
if (!controller) return;
setSelectedIds(controller.selectById(elementId, multiMode));
setCandidateMenu(undefined);
requestAnimationFrame(() => candidateTriggerRef.current?.focus());
}
function closeCandidateMenu() {
setCandidateMenu(undefined);
requestAnimationFrame(() => candidateTriggerRef.current?.focus());
}
function handleCandidateMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
closeCandidateMenu();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')];
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const delta = event.key === "ArrowDown" ? 1 : -1;
const next = (current + delta + items.length) % items.length;
event.preventDefault();
items[next]?.focus();
}
function clearSelection() {
@@ -752,19 +791,22 @@ export function EditorPage({ projectId }: { projectId: string }) {
: undefined;
return (
<div className="editor-page-shell">
<header className="editor-toolbar">
<a aria-label="返回项目" className="editor-back" href={`/app/projects/${projectId}`}></a>
<div className="editor-title"><strong>{project.name}</strong><span>{project.ratio}</span></div>
<div className="editor-history-actions">
<button aria-label="撤销" disabled={!historyRef.current?.canUndo || !canEdit} onClick={undo} title="撤销" type="button"></button>
<button aria-label="重做" disabled={!historyRef.current?.canRedo || !canEdit} onClick={redo} title="重做" type="button"></button>
<a className="editor-skip-link" href="#editor-main"></a>
<header aria-label="编辑器顶部区域" className="editor-toolbar">
<div aria-label="编辑器顶部工具栏" className="editor-toolbar-controls" role="toolbar">
<a aria-label="返回项目" className="editor-back" href={`/app/projects/${projectId}`}></a>
<div className="editor-title"><h1>{project.name}</h1><span>{project.ratio}</span></div>
<div className="editor-history-actions">
<button aria-label="撤销" disabled={!historyRef.current?.canUndo || !canEdit} onClick={undo} title="撤销" type="button"></button>
<button aria-label="重做" disabled={!historyRef.current?.canRedo || !canEdit} onClick={redo} title="重做" type="button"></button>
</div>
<span aria-live={saveStatus === "failed" || saveStatus === "conflicted" ? "assertive" : "polite"} className={`editor-save-status ${saveStatus}`}>{saveStatusLabel(saveStatus)}</span>
<button disabled type="button"></button>
<button disabled={!canvasState.background.asset_id || (saveStatus === "conflicted" && conflictExportGuardRef.current.used)} onClick={openExportDialog} type="button"></button>
</div>
<span aria-live={saveStatus === "failed" || saveStatus === "conflicted" ? "assertive" : "polite"} className={`editor-save-status ${saveStatus}`}>{saveStatusLabel(saveStatus)}</span>
<button disabled type="button"></button>
<button disabled={!canvasState.background.asset_id || (saveStatus === "conflicted" && conflictExportGuardRef.current.used)} onClick={openExportDialog} type="button"></button>
</header>
{notice ? <p aria-live="polite" className="editor-notice" role="status">{notice}</p> : null}
<main className="editor-layout">
<main className="editor-layout" id="editor-main" tabIndex={-1}>
<aside aria-label="素材与底图来源" className="editor-assets-panel">
<nav aria-label="编辑器素材分类" className="editor-asset-tabs">
{([
@@ -801,7 +843,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
<section aria-label="画布工作区" className="editor-workspace">
<div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
<button aria-pressed={multiMode} className={multiMode ? "active" : ""} disabled={!canEdit} onClick={() => setMultiMode((current) => !current)} type="button"></button>
<button disabled={selectedElements.length === 0} onClick={() => { const first = selectedElements[0]; if (first) showCandidates(first.position); }} type="button"></button>
<button disabled={canvasState.elements.length === 0} onClick={showAllCandidates} ref={candidateTriggerRef} type="button"></button>
<button disabled={selectedElements.length === 0 || !canEdit} onClick={copySelection} title="复制" type="button"></button>
<button disabled={clipboardRef.current.length === 0 || !canEdit} onClick={pasteSelection} title="粘贴" type="button"></button>
</div>
@@ -825,7 +867,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
projectId={projectId}
selectedIds={selectedIds}
/>
{candidateMenu ? <div className="editor-candidates" role="menu" style={{ left: `${candidateMenu.point.x * 100}%`, top: `${candidateMenu.point.y * 100}%` }}>
{candidateMenu ? <div aria-label="画布对象候选" className="editor-candidates" onKeyDown={handleCandidateMenuKeyDown} ref={candidateMenuRef} role="menu" style={{ left: `${candidateMenu.point.x * 100}%`, top: `${candidateMenu.point.y * 100}%` }}>
{candidateMenu.elements.map((element) => <button key={element.element_id} onClick={() => chooseCandidate(element.element_id)} role="menuitem" type="button">{element.content || `${element.type} · ${element.template_or_asset_id}`}</button>)}
</div> : null}
</div>
@@ -881,15 +923,15 @@ export function EditorPage({ projectId }: { projectId: string }) {
</>}
</aside>
</main>
<footer className="editor-statusbar"><span> {canvasState.pixel_width} × {canvasState.pixel_height}</span><span> {canvasState.elements.length} / 50</span><span> 100%</span><span> · state version {project.state_version}</span></footer>
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm"></h2><p></p><div><button className="editor-primary" onClick={() => { void confirmBackground(); }} type="button"></button><button onClick={() => setPendingBackground(undefined)} type="button"></button></div></section></div> : null}
{locationDialog ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-location-consent" aria-modal="true" className="editor-confirm editor-location-consent" role="dialog">
<footer aria-label="画布状态" className="editor-statusbar"><span> {canvasState.pixel_width} × {canvasState.pixel_height}</span><span> {canvasState.elements.length} / 50</span><span> 100%</span><span> · state version {project.state_version}</span></footer>
{pendingBackground ? <EditorDialog labelledBy="editor-background-confirm" onClose={() => setPendingBackground(undefined)}><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm"></h2><p></p><div><button className="editor-primary" data-dialog-initial-focus onClick={() => { void confirmBackground(); }} type="button"></button><button onClick={() => setPendingBackground(undefined)} type="button"></button></div></EditorDialog> : null}
{locationDialog ? <EditorDialog className="editor-confirm editor-location-consent" labelledBy="editor-location-consent" onClose={() => { if (!locationDialog.pending) setLocationDialog(undefined); }}>
<p>LOCATION PRIVACY</p><h2 id="editor-location-consent">使</h2>
<p></p>
{locationDialog.error ? <p className="editor-limit" role="alert">{locationDialog.error}</p> : null}
<label><input aria-label="手动地点文字" disabled={locationDialog.pending} onChange={(event) => setLocationDialog((current) => current ? { ...current, manualValue: event.target.value } : current)} value={locationDialog.manualValue} /></label>
<div className="editor-location-actions"><button className="editor-primary" disabled={locationDialog.pending} onClick={() => { void confirmAutomaticLocation(); }} type="button">{locationDialog.pending ? "正在定位" : "同意并自动定位"}</button><button disabled={!locationDialog.manualValue.trim() || locationDialog.pending} onClick={() => { void addDynamicSticker("DYN001", { formattedValue: locationDialog.manualValue.trim() }); }} type="button"></button><button disabled={locationDialog.pending} onClick={() => setLocationDialog(undefined)} type="button"></button></div>
</section></div> : null}
<div className="editor-location-actions"><button className="editor-primary" data-dialog-initial-focus disabled={locationDialog.pending} onClick={() => { void confirmAutomaticLocation(); }} type="button">{locationDialog.pending ? "正在定位" : "同意并自动定位"}</button><button disabled={!locationDialog.manualValue.trim() || locationDialog.pending} onClick={() => { void addDynamicSticker("DYN001", { formattedValue: locationDialog.manualValue.trim() }); }} type="button"></button><button disabled={locationDialog.pending} onClick={() => setLocationDialog(undefined)} type="button"></button></div>
</EditorDialog> : null}
{exportOpen ? <ExportDialog
busy={exportBusy}
onClose={() => { if (!exportBusy) { setExportOpen(false); setExportResult(undefined); } }}
+6 -4
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useDialogFocus } from "./dialog-focus.js";
import { exportResultCopy, type ExportFlowStatus } from "./export-flow.js";
import { EXPORT_DIMENSIONS, type ExportFormat, type ExportRatio } from "./export-compositor.js";
@@ -18,21 +19,22 @@ export function ExportDialog(props: {
const [pendingConfirmed, setPendingConfirmed] = useState(false);
const dimensions = EXPORT_DIMENSIONS[props.ratio];
const failure = props.result && props.result !== "downloaded_and_saved";
return <div className="editor-dialog-backdrop"><section aria-labelledby="editor-export-title" aria-modal="true" className="editor-export-dialog" role="dialog">
<header><div><p>EXPORT</p><h2 id="editor-export-title"></h2></div><button aria-label="关闭导出" disabled={props.busy} onClick={props.onClose} title="关闭" type="button">×</button></header>
const dialogRef = useDialogFocus(props.onClose);
return <div className="editor-dialog-backdrop"><section aria-labelledby="editor-export-title" aria-modal="true" className="editor-export-dialog" ref={dialogRef} role="dialog" tabIndex={-1}>
<header><div><p>EXPORT</p><h2 id="editor-export-title"></h2></div></header>
{props.result ? <div className={`editor-export-result ${failure ? "failed" : "succeeded"}`}>
<p aria-live={failure ? "assertive" : "polite"} role={failure ? "alert" : "status"}>{exportResultCopy[props.result]}</p>
{props.byteSize ? <small>{(props.byteSize / 1024 / 1024).toFixed(2)} MB</small> : null}
<div>{props.result === "download_failed" ? <button disabled={props.busy} onClick={props.onRetry} type="button"></button> : null}<button disabled={props.busy} onClick={props.onClose} type="button"></button></div>
</div> : <>
<section className="editor-export-section"><h3></h3><div aria-label="导出格式" className="editor-export-segments" role="group">
<button aria-pressed={format === "jpg"} onClick={() => setFormat("jpg")} type="button">JPG</button>
<button aria-pressed={format === "jpg"} data-dialog-initial-focus onClick={() => setFormat("jpg")} type="button">JPG</button>
<button aria-pressed={format === "png"} onClick={() => setFormat("png")} type="button">PNG</button>
</div></section>
{format === "jpg" ? <section className="editor-export-section"><div className="editor-export-label"><h3>JPG </h3><output>{quality}</output></div><div className="editor-export-quality"><input aria-label="JPG 质量" max="100" min="80" onChange={(event) => setQuality(Number(event.target.value))} step="1" type="range" value={quality} /><input aria-label="JPG 质量数值" max="100" min="80" onChange={(event) => setQuality(Math.max(80, Math.min(100, Number(event.target.value))))} step="1" type="number" value={quality} /></div></section> : null}
<section className="editor-export-summary"><span></span><strong>{dimensions.width} × {dimensions.height} px</strong><span></span><strong>sRGB</strong><span></span><strong></strong></section>
{props.pendingEdit ? <label className="editor-export-pending"><strong></strong><span><input checked={pendingConfirmed} onChange={(event) => setPendingConfirmed(event.target.checked)} type="checkbox" /></span></label> : null}
<footer><button disabled={props.busy} onClick={props.onClose} type="button"></button><button className="editor-primary" disabled={props.busy || (props.pendingEdit && !pendingConfirmed)} onClick={() => props.onExport({ format, ...(format === "jpg" ? { quality } : {}) })} type="button">{props.busy ? "正在导出" : "导出并下载"}</button></footer>
<footer><button className="editor-primary" disabled={props.busy || (props.pendingEdit && !pendingConfirmed)} onClick={() => props.onExport({ format, ...(format === "jpg" ? { quality } : {}) })} type="button">{props.busy ? "正在导出" : "导出并下载"}</button><button disabled={props.busy} onClick={props.onClose} type="button"></button></footer>
</>}
</section></div>;
}