From 2070b52023cae9348ad5a80eeab2a9a030adf90f Mon Sep 17 00:00:00 2001 From: Jaybe Date: Mon, 17 Nov 2025 01:14:30 +0900 Subject: [PATCH] =?UTF-8?q?WYSIWYG:=20a11y=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=20=EB=A6=AC=EC=A0=84=20=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?\n-=20=EC=84=A0=ED=83=9D=20=EA=B0=9C=EC=88=98/=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20=EB=B0=8F=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20=EC=95=88=EB=82=B4(useMemo=20=EA=B8=B0=EB=B0=98)\n-?= =?UTF-8?q?=20a11y=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80(guid?= =?UTF-8?q?es/nudge)\n-=20=EB=A9=94=EC=9D=B8=ED=94=8C=EB=9E=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8\n-=20=EC=A0=84=EC=B2=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B7=B8=EB=A6=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MAIN_PLAN.md | 13 ++++ .../wysiwyg/Canvas.a11y.guides.test.tsx | 59 +++++++++++++++++++ components/wysiwyg/Canvas.a11y.nudge.test.tsx | 52 ++++++++++++++++ components/wysiwyg/Canvas.tsx | 10 ++++ 4 files changed, 134 insertions(+) create mode 100644 components/wysiwyg/Canvas.a11y.guides.test.tsx create mode 100644 components/wysiwyg/Canvas.a11y.nudge.test.tsx diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 3b5a4f6..ff27174 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -64,6 +64,19 @@ - 멀티선택 혼합/최근접 보강 케이스 수평축/복합 시나리오 추가 점검 - 접근성 보강: 선택/스냅/가이드 상태에 대한 라이브 리전 안내 및 ARIA 속성 방안 설계 +### 접근성 라이브 리전(2025-11-17) +- 목표: 캔버스에서 선택 개수 변화와 가이드(스냅 라인) 좌표 변화를 스크린리더에 폴라이트로 안내 +- TDD + - `components/wysiwyg/Canvas.a11y.guides.test.tsx` + - 초기 선택 1개 안내, Shift+클릭 후 2개 안내 + - 드래그로 가이드 표시 시 `x/y` 좌표 포함하여 라이브 리전 텍스트 갱신 검증 +- 구현: `components/wysiwyg/Canvas.tsx` + - `role="status" aria-live="polite"` 라이브 리전 요소 추가(`data-testid="aria-live"`) + - 텍스트는 `useMemo`로 파생 계산(선택 수, 가이드 표시 여부, 가이드 좌표) + - 상태(setState) 기반 이펙트 대신 파생 텍스트로 전환하여 렌더 루프/린트 경고 회피 +- 결과: 테스트 그린(전체 232/232) +- 디버깅 메모: useEffect 내 setState 경고 발생 → useMemo로 파생 텍스트 계산으로 해결 + ## 목표 - 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더. - 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용. diff --git a/components/wysiwyg/Canvas.a11y.guides.test.tsx b/components/wysiwyg/Canvas.a11y.guides.test.tsx new file mode 100644 index 0000000..865dcad --- /dev/null +++ b/components/wysiwyg/Canvas.a11y.guides.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent, waitFor } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', + width: 320, + height: 240, + background: '#ffffff', + layers: [ + { + id: 'l1', + name: 'L1', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'image', x: 16, y: 16, width: 32, height: 32, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } }, + { id: 'o2', type: 'image', x: 80, y: 16, width: 32, height: 32, rotate: 0, zIndex: 1, props: { src: 'b', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas a11y live region', () => { + it('announces selection count and guideline position changes', async () => { + const frame = makeFrame() + const { getByTestId } = render() + + const live = getByTestId('aria-live') + + // initial selection exists (first object) + await waitFor(() => { + expect(live.textContent).toMatch(/선택\s*1개/) + }) + + // add second selection via Shift+click + fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 80, clientY: 16, shiftKey: true }) + await waitFor(() => { + expect(live.textContent).toMatch(/선택\s*2개/) + }) + + // start dragging o2 to trigger guide updates + const canvas = getByTestId('wysiwyg-canvas') + fireEvent.mouseDown(getByTestId('obj-o2'), { clientX: 80, clientY: 16 }) + fireEvent.mouseMove(canvas, { clientX: 96, clientY: 24 }) + + await waitFor(() => { + // guide should be visible and aria-live includes guide coordinates + const t = live.textContent || '' + expect(t).toMatch(/가이드\s*x=\d+/) + expect(t).toMatch(/y=\d+/) + }) + + fireEvent.mouseUp(canvas) + }) +}) diff --git a/components/wysiwyg/Canvas.a11y.nudge.test.tsx b/components/wysiwyg/Canvas.a11y.nudge.test.tsx new file mode 100644 index 0000000..4a48d7b --- /dev/null +++ b/components/wysiwyg/Canvas.a11y.nudge.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent, waitFor } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', + width: 200, + height: 160, + background: '#ffffff', + layers: [ + { + id: 'l1', + name: 'L1', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'image', x: 10, y: 12, width: 32, height: 24, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas a11y live region for keyboard nudge', () => { + it('announces selected object coordinates after Arrow key nudge', async () => { + const frame = makeFrame() + const { getByTestId } = render() + + const canvas = getByTestId('wysiwyg-canvas') + const live = getByTestId('aria-live') + + // Focus and nudge to the right by 1 (snap8 applies → from 10 to 8 or 16 depending on rounding) + canvas.focus() + fireEvent.keyDown(canvas, { key: 'ArrowRight' }) + + await waitFor(() => { + const t = live.textContent || '' + expect(t).toMatch(/선택\s*1개/) + expect(t).toMatch(/위치\s*x=\d+/) + expect(t).toMatch(/y=\d+/) + }) + + // Nudge down + fireEvent.keyDown(canvas, { key: 'ArrowDown' }) + await waitFor(() => { + const t = live.textContent || '' + expect(t).toMatch(/위치\s*x=\d+,\s*y=\d+/) + }) + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 6a8ff8a..8cbf225 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -56,6 +56,15 @@ export function Canvas({ frame, onChange }: CanvasProps) { return null }, [model, selectedId]) + // a11y 라이브 리전 텍스트: 선택 개수 및 가이드 좌표를 폴라이트로 알림 + const ariaLiveText = useMemo(() => { + const count = selectedIds.length + const base = `선택 ${count}개` + const pos = selected ? `, 위치 x=${selected.x}, y=${selected.y}` : '' + const guide = showGuides ? `, 가이드 x=${guidePos.x}, y=${guidePos.y}` : '' + return `${base}${pos}${guide}` + }, [selectedIds.length, showGuides, guidePos.x, guidePos.y, selected?.x, selected?.y]) + const updateObject = useCallback((id: string, updater: (o: WObject) => WObject) => { setModel((prev) => { const next: Frame = { ...prev, layers: prev.layers.map((ly) => ({ ...ly, objects: ly.objects.map((o) => (o.id === id ? updater(o) : o)) })) } @@ -263,6 +272,7 @@ export function Canvas({ frame, onChange }: CanvasProps) {
)} +
{ariaLiveText}
{model.layers.map((layer) => layer.visible !== false && layer.objects.map((o) => (