feat: complete TASK-WP4-06 accessibility
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); } }}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
+5
-2
@@ -14,7 +14,7 @@
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts --config playwright.config.ts",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts --config playwright.config.ts",
|
||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||
@@ -83,7 +83,9 @@
|
||||
"test:wp4-04": "node scripts/run-wp4-04-validation.mjs",
|
||||
"test:wp4-04:red": "node scripts/run-wp4-04-validation.mjs --phase red",
|
||||
"test:wp4-05": "node scripts/run-wp4-05-validation.mjs",
|
||||
"test:wp4-05:red": "node scripts/run-wp4-05-validation.mjs --phase red"
|
||||
"test:wp4-05:red": "node scripts/run-wp4-05-validation.mjs --phase red",
|
||||
"test:wp4-06": "node scripts/run-wp4-06-validation.mjs",
|
||||
"test:wp4-06:red": "node scripts/run-wp4-06-validation.mjs --phase red"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.0",
|
||||
@@ -91,6 +93,7 @@
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"axe-core": "4.12.1",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "4.1.10"
|
||||
|
||||
Generated
+9
@@ -23,6 +23,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
axe-core:
|
||||
specifier: 4.12.1
|
||||
version: 4.12.1
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
@@ -570,6 +573,10 @@ packages:
|
||||
avvio@9.3.0:
|
||||
resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
|
||||
|
||||
axe-core@4.12.1:
|
||||
resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -1740,6 +1747,8 @@ snapshots:
|
||||
'@fastify/error': 4.2.0
|
||||
fastq: 1.20.1
|
||||
|
||||
axe-core@4.12.1: {}
|
||||
|
||||
base64-js@1.5.1:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export const frozenPackages = {
|
||||
"package.json": {
|
||||
devDependencies: {
|
||||
"@playwright/test": "1.62.0",
|
||||
"axe-core": "4.12.1",
|
||||
typescript: "7.0.2",
|
||||
vite: "8.1.5",
|
||||
vitest: "4.1.10",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const phaseIndex = process.argv.indexOf("--phase");
|
||||
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||
const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-06-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP4-A11Y-001-keyboard-focus");
|
||||
const pageDirectory = resolve(caseDirectory, "pages", "editor");
|
||||
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||
mkdirSync(pageDirectory, { recursive: true });
|
||||
|
||||
const outputDirectory = resolve(runDirectory, "playwright-output");
|
||||
const environment = { ...process.env, DADA_EVIDENCE_DIR_A11Y: caseDirectory, DADA_PLAYWRIGHT_OUTPUT_DIR: outputDirectory };
|
||||
const commands = phase === "red" ? [] : [
|
||||
["e2e", "pnpm test:e2e"],
|
||||
["visual", "pnpm test:visual"],
|
||||
["performance", "pnpm test:performance"],
|
||||
["tdd-trace", "pnpm validate:tdd-trace"],
|
||||
];
|
||||
const commandResults = [];
|
||||
for (const [name, command] of commands) {
|
||||
const started_at = new Date().toISOString();
|
||||
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
||||
}
|
||||
|
||||
function traces(directory) {
|
||||
if (!existsSync(directory)) return [];
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
return entry.isDirectory() ? traces(path) : entry.name === "trace.zip" ? [path] : [];
|
||||
});
|
||||
}
|
||||
if (phase === "green") {
|
||||
const trace = traces(outputDirectory).find((path) => path.includes("wp4-06-accessibility"));
|
||||
if (trace) copyFileSync(trace, resolve(pageDirectory, "trace.zip"));
|
||||
} else {
|
||||
writeFileSync(resolve(caseDirectory, "red-observation.json"), `${JSON.stringify({
|
||||
expected_failure: "Editor had no first-tab skip link, modal focus trap/return, keyboard all-object selection, or reachable export at 200 percent equivalent viewport",
|
||||
observed_command: "pnpm playwright test tests/e2e/wp4-06-accessibility.spec.ts --config playwright.config.ts",
|
||||
observed_error: "getByRole('link', { name: '跳到主要内容' }) did not exist",
|
||||
status: "red_confirmed",
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const evidenceRefs = phase === "red" ? ["red-observation.json"] : [
|
||||
"pages/editor/axe.json", "pages/editor/focus-trace.json", "pages/editor/trace.zip", "pages/editor/screenshots/200pct.png",
|
||||
];
|
||||
const missing = evidenceRefs.filter((file) => !existsSync(resolve(caseDirectory, file)));
|
||||
const commandState = phase === "red" || commandResults.every((result) => result.exit_code === 0);
|
||||
const status = commandState && missing.length === 0 ? phase === "red" ? "red_confirmed" : "passed" : "failed";
|
||||
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
|
||||
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||
writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify({
|
||||
acceptance_criteria: ["AC-32"], automation: ["automated"], commit, evidence_refs: evidenceRefs,
|
||||
layer: ["E2E", "VIS-PERF"], manifest, missing_evidence: missing, phase, requirements: ["NFR-06"],
|
||||
run_id: runId, status, task_id: "TASK-WP4-06", test_id: "TDD-WP4-A11Y-001-keyboard-focus", work_package: "WP-4",
|
||||
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
||||
}, null, 2)}\n`);
|
||||
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: [{ missing_evidence: missing, status, test_id: "TDD-WP4-A11Y-001-keyboard-focus" }], phase, run_id: runId, status }, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ cases: [{ missing_evidence: missing, status, test_id: "TDD-WP4-A11Y-001-keyboard-focus" }], phase, run_id: runId, status }, null, 2));
|
||||
if (status === "failed") process.exit(1);
|
||||
@@ -0,0 +1,189 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
import axe from "axe-core";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
const projectId = "00000000-0000-4000-8000-000000000940";
|
||||
const firstImageId = "00000000-0000-4000-8000-000000000941";
|
||||
const secondImageId = "00000000-0000-4000-8000-000000000942";
|
||||
|
||||
const session = {
|
||||
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-editor-a11y-000000000000000000000000000000000",
|
||||
expires_at: "2026-09-03T08:00:00.000Z",
|
||||
user: { creator_name: "最长可访问性测试创作署名不会遮挡主动作", role: "user", social_id: "@a11y_user", status: "active", user_id: "00000000-0000-4000-8000-000000000943" },
|
||||
};
|
||||
|
||||
function initialCanvas(): CanvasState {
|
||||
return {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: firstImageId },
|
||||
elements: [{
|
||||
created_at: "2026-08-03T07:00:00.000Z", element_id: "00000000-0000-4000-8000-000000000944", opacity: 1,
|
||||
position: { x: 0.5, y: 0.5 }, resource_version: "fixture-v1", rotation: 0, scale: { x: 1, y: 1 },
|
||||
style_parameters: { flip_horizontal: false }, template_or_asset_id: "STK001", type: "static_sticker", z_index: 0,
|
||||
}],
|
||||
pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Backend { canvas: CanvasState; saves: number; version: number }
|
||||
|
||||
function evidence(file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_A11Y;
|
||||
if (!root) return;
|
||||
const path = resolve(root, "pages", "editor", file);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function routeEditor(page: Page, backend: Backend) {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json" }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
canvas_state: backend.canvas, created_at: "2026-08-03T07:00:00.000Z", current_image_id: firstImageId,
|
||||
images: [firstImageId, secondImageId].map((imageId, index) => ({ created_at: `2026-08-03T0${index + 7}:00:00.000Z`, generation_id: `00000000-0000-4000-8000-00000000095${index}`, image_id: imageId })),
|
||||
name: "最长项目名称用于检查百分之二百缩放时主动作仍然完整可达", project_id: projectId, ratio: "3:4", state_version: backend.version,
|
||||
}), contentType: "application/json",
|
||||
}));
|
||||
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
|
||||
backend.saves += 1;
|
||||
backend.version += 1;
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 80));
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json" });
|
||||
});
|
||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1440"><rect width="1080" height="1440" fill="#39d98a"/><circle cx="540" cy="720" r="180" fill="#111111"/></svg>',
|
||||
contentType: "image/svg+xml",
|
||||
}));
|
||||
await page.route("**/api/v1/assets/public/fixture-v1/STK001", (route) => route.fulfill({
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><rect width="120" height="120" fill="#f2f400"/></svg>', contentType: "image/svg+xml",
|
||||
}));
|
||||
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
|
||||
}
|
||||
|
||||
async function activeName(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
return active?.getAttribute("aria-label") || active?.textContent?.trim() || active?.id || active?.tagName || "";
|
||||
});
|
||||
}
|
||||
|
||||
async function resetTabStart(page: Page) {
|
||||
await expect(page.getByRole("link", { name: "跳到主要内容" })).toBeAttached();
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
}
|
||||
|
||||
test("TDD-WP4-A11Y-001 keyboard focus keeps editor actions, dialogs, status, and 200 percent layout reachable", async ({ page }) => {
|
||||
const backend: Backend = { canvas: initialCanvas(), saves: 0, version: 6 };
|
||||
const focusTrace: Array<{ active: string; step: string }> = [];
|
||||
await routeEditor(page, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await resetTabStart(page);
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
focusTrace.push({ active: await activeName(page), step: "first-tab" });
|
||||
await expect(page.getByRole("link", { name: "跳到主要内容" })).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#editor-main")).toBeFocused();
|
||||
focusTrace.push({ active: await activeName(page), step: "skip-activated" });
|
||||
|
||||
await page.reload();
|
||||
await resetTabStart(page);
|
||||
for (const step of ["skip", "back", "export", "first-left-tab"]) {
|
||||
await page.keyboard.press("Tab");
|
||||
focusTrace.push({ active: await activeName(page), step });
|
||||
}
|
||||
expect(focusTrace.slice(-4).map((item) => item.active)).toEqual(["跳到主要内容", "返回项目", "导出", "底图"]);
|
||||
|
||||
const historyTab = page.getByRole("button", { name: "历史", exact: true });
|
||||
await historyTab.focus(); await page.keyboard.press("Enter");
|
||||
const backgroundTrigger = page.locator(".editor-history-list button").last();
|
||||
await backgroundTrigger.focus(); await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "更换底图" }).getByRole("button", { name: "确认更换" })).toBeFocused();
|
||||
focusTrace.push({ active: await activeName(page), step: "background-dialog-initial" });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(backgroundTrigger).toBeFocused();
|
||||
|
||||
const dynamicTab = page.getByRole("button", { name: "动态贴纸", exact: true });
|
||||
await dynamicTab.focus(); await page.keyboard.press("Enter");
|
||||
const locationTrigger = page.getByRole("button", { name: /添加动态贴纸 DYN004/ });
|
||||
await locationTrigger.focus(); await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "使用自动定位" }).getByRole("button", { name: "同意并自动定位" })).toBeFocused();
|
||||
focusTrace.push({ active: await activeName(page), step: "location-dialog-initial" });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(locationTrigger).toBeFocused();
|
||||
|
||||
const exportTrigger = page.getByRole("button", { name: "导出", exact: true });
|
||||
await exportTrigger.focus(); await page.keyboard.press("Enter");
|
||||
const exportDialog = page.getByRole("dialog", { name: "导出成品" });
|
||||
const jpg = exportDialog.getByRole("button", { name: "JPG", exact: true });
|
||||
await expect(jpg).toBeFocused();
|
||||
const exportOrder = [await activeName(page)];
|
||||
for (let index = 0; index < 5; index += 1) { await page.keyboard.press("Tab"); exportOrder.push(await activeName(page)); }
|
||||
expect(exportOrder).toEqual(["JPG", "PNG", "JPG 质量", "JPG 质量数值", "导出并下载", "取消"]);
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(jpg).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(exportDialog.getByRole("button", { name: "取消" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(exportTrigger).toBeFocused();
|
||||
focusTrace.push({ active: await activeName(page), step: "export-dialog-return" });
|
||||
|
||||
const cycle = page.getByRole("button", { name: "循环选择" });
|
||||
await cycle.focus(); await page.keyboard.press("Enter");
|
||||
const candidate = page.getByRole("menuitem", { name: /static_sticker.*STK001/ });
|
||||
await expect(candidate).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(cycle).toBeFocused();
|
||||
const moveRight = page.getByRole("button", { name: "向右移动" });
|
||||
await moveRight.focus(); await page.keyboard.press("Enter");
|
||||
await expect(moveRight).toBeFocused();
|
||||
await expect.poll(() => backend.canvas.elements[0]?.position.x).toBeCloseTo(0.51);
|
||||
|
||||
await page.getByLabel("编辑画布").press("Escape");
|
||||
const apply = page.getByRole("button", { name: "应用调整" });
|
||||
await apply.focus(); await page.keyboard.press("Enter");
|
||||
await expect(page.locator(".editor-save-status")).toHaveText(/有未保存修改|正在保存|已保存/);
|
||||
await expect.poll(() => backend.saves).toBeGreaterThan(1);
|
||||
await expect(apply).toBeFocused();
|
||||
expect(await page.locator('[tabindex]:not([tabindex="0"]):not([tabindex="-1"])').count()).toBe(0);
|
||||
|
||||
await page.addScriptTag({ content: axe.source });
|
||||
const axeResult = await page.evaluate(async () => {
|
||||
const report = await (window as Window & { axe: typeof axe }).axe.run(document, { resultTypes: ["violations"] });
|
||||
return { violations: report.violations.map((violation) => ({ id: violation.id, impact: violation.impact, nodes: violation.nodes.map((node) => ({ html: node.html, target: node.target })) })) };
|
||||
});
|
||||
const blocking = axeResult.violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious");
|
||||
expect(blocking).toEqual([]);
|
||||
evidence("axe.json", { blocking, ...axeResult });
|
||||
|
||||
await page.setViewportSize({ width: 640, height: 360 });
|
||||
await expect(exportTrigger).toBeVisible();
|
||||
const overflow = await page.evaluate(() => ({ client_width: document.documentElement.clientWidth, scroll_width: document.documentElement.scrollWidth }));
|
||||
expect(overflow.scroll_width).toBeLessThanOrEqual(overflow.client_width);
|
||||
const screenshotRoot = process.env.DADA_EVIDENCE_DIR_A11Y;
|
||||
if (screenshotRoot) {
|
||||
const screenshot = resolve(screenshotRoot, "pages", "editor", "screenshots", "200pct.png");
|
||||
mkdirSync(dirname(screenshot), { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: screenshot });
|
||||
}
|
||||
focusTrace.push({ active: await activeName(page), step: "200-percent-equivalent-viewport" });
|
||||
evidence("focus-trace.json", { focus_trace: focusTrace, modal_export_order: exportOrder, no_positive_tabindex: true, overflow });
|
||||
});
|
||||
Reference in New Issue
Block a user