From cd7bfcfc24167435defeb7e23ad1c431ba110689 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:49:03 -0700 Subject: [PATCH 01/10] a11y(frontend): keep unavailable export actions discoverable --- CHANGELOG.md | 1 + ...discoverable-unavailable-export-actions.md | 41 +++++ .../components/modals/ExportModal.test.tsx | 142 ++++++++++++++---- .../src/components/modals/ExportModal.tsx | 18 ++- 4 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 docs/doctoring/discoverable-unavailable-export-actions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..8a4108e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [FE] ♿ **비활성 내보내기 작업의 발견 가능성**: 아직 사용할 수 없는 내보내기·접근 관리 작업을 `aria-disabled` 상태의 포커스 가능한 버튼으로 유지하고, 보이는 선행조건 설명을 `aria-describedby`로 연결합니다. 포인터·Enter·Space 실행은 차단하며 준비된 작업의 기존 콜백은 그대로 유지합니다. - [BE] 🔒 **Cryptography 50+ 보안 경계 갱신**: `pyproject.toml`과 두 hash-locked 요구사항 파일을 동일한 Cryptography 50+ 해석으로 정합화하여 PKCS#7 오류·타이밍 구분으로 인한 CVE-2026-69247 완화를 실제 설치·검증 경로에 반영했습니다. - [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다. - [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다. diff --git a/docs/doctoring/discoverable-unavailable-export-actions.md b/docs/doctoring/discoverable-unavailable-export-actions.md new file mode 100644 index 000000000..d71d023ed --- /dev/null +++ b/docs/doctoring/discoverable-unavailable-export-actions.md @@ -0,0 +1,41 @@ +# Discoverable unavailable export actions + +## Decision + +Export and access-management actions that are temporarily unavailable remain ordinary focusable HTML buttons. They expose `aria-disabled="true"` and reference the visible prerequisite message with `aria-describedby`. The component suppresses pointer, Enter, and Space activation while unavailable. When the prerequisite exists, `aria-disabled` is omitted and the original callback runs. + +This differs deliberately from the native `disabled` attribute. Native disabled controls are removed from the sequential focus order in common browser behavior, which can make the action catalogue and the reason for unavailability difficult to discover for keyboard and screen-reader users. `aria-disabled` communicates an inoperable state without providing behavior; the application therefore owns both event suppression and a visible unavailable style. + +## Scope and invariants + +- Every unavailable export format remains reachable by Tab. +- The state is perceivable through `aria-disabled` and the same visible explanation is programmatically associated through `aria-describedby`. +- Click, Enter, and Space cannot invoke an unavailable callback. +- Keyboard suppression does not interfere with Tab navigation. +- Available actions omit `aria-disabled` and preserve their existing activation behavior. +- The access-management placeholder follows the same focusable, described, inert contract. +- No export permission, serialization, download, or data-readiness rule is weakened. + +## Verification + +Focused component tests enumerate all eight unavailable actions and assert DOM-level focusability, `aria-disabled="true"`, the descriptive relationship, pointer and keyboard inertness, and restoration of normal callback behavior when available. The complete frontend typecheck, 100% statement/branch/function/line coverage suite, production build, security scans, and exact-head independent review remain mandatory. + +## Monitoring and rollback + +Monitor keyboard-only completion, support requests about unavailable exports, accidental callback invocation, and accessibility-regression results. Rollback must not silently remove the explanation from keyboard access; a replacement design must preserve discoverability and explicit inertness. + +## Standards status + +WAI-ARIA 1.2 is the published normative baseline used here. WAI-ARIA 1.3 describes the same perceivable-but-inoperable state and is monitored as a 2026 working draft, not treated as a final standard. + +The user-study evidence below supports the broader product rationale for making an unavailable or inaccessible action and its reason discoverable. It does not by itself validate this specific `aria-disabled` implementation; the normative state semantics remain governed by WAI-ARIA and the repository's executable component tests. + +## References + +World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +World Wide Web Consortium. (2026). *Accessible Rich Internet Applications (WAI-ARIA) 1.3* (Working Draft). https://www.w3.org/TR/wai-aria-1.3/ + +Bigham, J. P., Lin, I., & Savage, S. (2017). The effects of “not knowing what you don’t know” on web accessibility for blind web users. In *Proceedings of the 19th International ACM SIGACCESS Conference on Computers and Accessibility* (pp. 101–109). Association for Computing Machinery. https://doi.org/10.1145/3132525.3132533 + +Research relevance: In a study of 30 blind and 30 sighted web users, uncertainty about whether task-relevant information was absent, inaccessible, or merely difficult to locate caused frustration and wasted time. That finding supports exposing an unavailable action together with an explicit prerequisite or recovery explanation instead of making the action silently undiscoverable. diff --git a/frontend/src/components/modals/ExportModal.test.tsx b/frontend/src/components/modals/ExportModal.test.tsx index bd89c5384..8a044b3d8 100644 --- a/frontend/src/components/modals/ExportModal.test.tsx +++ b/frontend/src/components/modals/ExportModal.test.tsx @@ -1,5 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ExportModal } from './ExportModal'; @@ -96,7 +97,7 @@ describe('ExportModal', () => { expect(screen.getByRole('button', { name: '생성 중...' })).toBeDisabled(); }); - it('runs each export artifact action from the modal', () => { + it('runs each export artifact action exactly once for click, Enter, and Space', async () => { const onCopyExportDdl = vi.fn(); const onDownloadSvg = vi.fn(); const onDownloadUml = vi.fn(); @@ -105,6 +106,7 @@ describe('ExportModal', () => { const onExportDictionaryMarkdown = vi.fn(); const onDownloadDbml = vi.fn(); const onDownloadPrisma = vi.fn(); + const user = userEvent.setup(); render( { />, ); - fireEvent.click(screen.getByRole('button', { name: 'SQL DDL 복사' })); - fireEvent.click(screen.getByRole('button', { name: 'SVG 이미지 내보내기' })); - fireEvent.click(screen.getByRole('button', { name: 'PlantUML 내보내기' })); - fireEvent.click(screen.getByRole('button', { name: 'Mermaid 내보내기' })); - fireEvent.click(screen.getByRole('button', { name: 'DBML 내보내기' })); - fireEvent.click(screen.getByRole('button', { name: 'Prisma Schema 내보내기' })); - fireEvent.click(screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' })); - fireEvent.click(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })); - - expect(onCopyExportDdl).toHaveBeenCalledOnce(); - expect(onDownloadSvg).toHaveBeenCalledOnce(); - expect(onDownloadUml).toHaveBeenCalledOnce(); - expect(onDownloadMermaid).toHaveBeenCalledOnce(); - expect(onDownloadDbml).toHaveBeenCalledOnce(); - expect(onDownloadPrisma).toHaveBeenCalledOnce(); - expect(onExportDictionaryCsv).toHaveBeenCalledOnce(); - expect(onExportDictionaryMarkdown).toHaveBeenCalledOnce(); + const enabledActions = [ + { button: screen.getByRole('button', { name: 'SQL DDL 복사' }), callback: onCopyExportDdl }, + { button: screen.getByRole('button', { name: 'SVG 이미지 내보내기' }), callback: onDownloadSvg }, + { button: screen.getByRole('button', { name: 'PlantUML 내보내기' }), callback: onDownloadUml }, + { button: screen.getByRole('button', { name: 'Mermaid 내보내기' }), callback: onDownloadMermaid }, + { button: screen.getByRole('button', { name: 'DBML 내보내기' }), callback: onDownloadDbml }, + { button: screen.getByRole('button', { name: 'Prisma Schema 내보내기' }), callback: onDownloadPrisma }, + { button: screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' }), callback: onExportDictionaryCsv }, + { button: screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' }), callback: onExportDictionaryMarkdown }, + ]; + + for (const { button, callback } of enabledActions) { + await user.click(button); + expect(callback).toHaveBeenCalledTimes(1); + + button.focus(); + await user.keyboard('{Enter}'); + expect(callback).toHaveBeenCalledTimes(2); + + button.focus(); + await user.keyboard(' '); + expect(callback).toHaveBeenCalledTimes(3); + } }); it('shows share link copy or creation errors', () => { @@ -150,34 +158,108 @@ describe('ExportModal', () => { expect(screen.getByRole('alert')).toHaveTextContent('공유 링크 복사에 실패했습니다.'); }); - it('explains when exports cannot be generated yet', () => { + it('explains and suppresses every export that cannot be generated yet', async () => { + const onCopyExportDdl = vi.fn(); + const onDownloadSvg = vi.fn(); + const onDownloadUml = vi.fn(); + const onDownloadMermaid = vi.fn(); + const onExportDictionaryCsv = vi.fn(); + const onExportDictionaryMarkdown = vi.fn(); + const onDownloadDbml = vi.fn(); + const onDownloadPrisma = vi.fn(); + const user = userEvent.setup(); render( , ); expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(8); - expect(screen.getByRole('button', { name: 'SQL DDL 복사' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'SVG 이미지 내보내기' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'PlantUML 내보내기' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Mermaid 내보내기' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'DBML 내보내기' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Prisma Schema 내보내기' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })).toBeDisabled(); + + const disabledButtons = [ + 'SQL DDL 복사', + 'SVG 이미지 내보내기', + 'PlantUML 내보내기', + 'Mermaid 내보내기', + 'DBML 내보내기', + 'Prisma Schema 내보내기', + '데이터 사전 CSV 내보내기', + '데이터 사전 Markdown 내보내기', + ]; + const descriptionIds = new Set(); + + for (const name of disabledButtons) { + const button = screen.getByRole('button', { name }); + expect(button).toHaveAttribute('aria-disabled', 'true'); + + const descriptionId = button.getAttribute('aria-describedby'); + expect(descriptionId).toBeTruthy(); + expect(descriptionIds.has(descriptionId!)).toBe(false); + descriptionIds.add(descriptionId!); + + const description = document.getElementById(descriptionId!); + expect(description).toBeVisible(); + expect(description).toHaveTextContent('먼저 테이블을 추가하세요'); + + button.focus(); + expect(button).toHaveFocus(); + await user.click(button); + await user.keyboard('{Enter}'); + await user.keyboard(' '); + } + + expect(descriptionIds.size).toBe(8); + expect(onCopyExportDdl).not.toHaveBeenCalled(); + expect(onDownloadSvg).not.toHaveBeenCalled(); + expect(onDownloadUml).not.toHaveBeenCalled(); + expect(onDownloadMermaid).not.toHaveBeenCalled(); + expect(onDownloadDbml).not.toHaveBeenCalled(); + expect(onDownloadPrisma).not.toHaveBeenCalled(); + expect(onExportDictionaryCsv).not.toHaveBeenCalled(); + expect(onExportDictionaryMarkdown).not.toHaveBeenCalled(); }); - it('exposes access-control guidance for disabled button', () => { - render(); + it('exposes access-control guidance and prevents each activation method', async () => { + const user = userEvent.setup(); + const observedDefaultPrevention = vi.fn(); + render( +
observedDefaultPrevention(event.defaultPrevented)}> + +
, + ); expect(screen.getByText('접근 권한 관리는 프로젝트 권한 설정에서 처리합니다.')).toBeInTheDocument(); const accessManagementButton = screen.getByRole('button', { name: '접근 관리' }); - expect(accessManagementButton).toBeDisabled(); + expect(accessManagementButton).toHaveAttribute('aria-disabled', 'true'); expect(accessManagementButton).toHaveAttribute('aria-describedby', 'share-export-access-hint'); expect(accessManagementButton).not.toHaveAttribute('title'); + + accessManagementButton.focus(); + expect(accessManagementButton).toHaveFocus(); + + await user.click(accessManagementButton); + expect(observedDefaultPrevention).toHaveBeenCalledTimes(1); + expect(observedDefaultPrevention).toHaveBeenLastCalledWith(true); + + accessManagementButton.focus(); + await user.keyboard('{Enter}'); + expect(observedDefaultPrevention).toHaveBeenCalledTimes(2); + expect(observedDefaultPrevention).toHaveBeenLastCalledWith(true); + + accessManagementButton.focus(); + await user.keyboard(' '); + expect(observedDefaultPrevention).toHaveBeenCalledTimes(3); + expect(observedDefaultPrevention).toHaveBeenLastCalledWith(true); }); }); diff --git a/frontend/src/components/modals/ExportModal.tsx b/frontend/src/components/modals/ExportModal.tsx index 995393ceb..34c3d1827 100644 --- a/frontend/src/components/modals/ExportModal.tsx +++ b/frontend/src/components/modals/ExportModal.tsx @@ -202,7 +202,8 @@ export function ExportModal({ )} ) : ( - + <> + + {isShareLinkUnavailable ? ( + + ) : null} + )}