diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..5704f8939 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..0b96a56c3 --- /dev/null +++ b/docs/doctoring/discoverable-unavailable-export-actions.md @@ -0,0 +1,45 @@ +# Discoverable unavailable export actions + +## Decision + +Share-link creation, export, and access-management actions that are unavailable because a prerequisite is missing 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. + +A short-lived busy state is a different contract from a missing prerequisite. While a share link is actively being created, the create button remains natively `disabled` with `aria-busy="true"` to suppress duplicate submission. When creation is unavailable only because no project is selected, the button remains focusable and is described by the visible next action, “먼저 프로젝트를 선택하세요.” + +## Scope and invariants + +- Every unavailable export format remains reachable by Tab. +- Share-link creation remains reachable by Tab when no project is selected and exposes the project-selection prerequisite through `aria-describedby`. +- 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. +- The transient share-link creation busy state remains natively disabled and exposes `aria-busy` so repeated activation cannot start duplicate work. +- No export permission, sharing permission, serialization, download, or data-readiness rule is weakened. + +## Verification + +Focused component tests enumerate all eight unavailable export actions and assert DOM-level focusability, `aria-disabled="true"`, the descriptive relationship, pointer and keyboard inertness, and restoration of normal callback behavior when available. A dedicated share-link regression asserts the same discoverable/inert contract when no project is selected, while the existing busy-state test preserves native disabling during active creation. 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 sharing or exports, accidental callback invocation, duplicate share-link creation, and accessibility-regression results. Rollback must not silently remove the explanation from keyboard access; a replacement design must preserve discoverability, explicit inertness, and the distinction between prerequisite-missing and actively-busy states. + +## Standards status + +WAI-ARIA 1.2 is the published normative baseline used here. WAI-ARIA 1.3 remains under development and is monitored as a 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/CHANGELOG.md b/frontend/CHANGELOG.md index c78944c31..6fdab84b6 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Added +- **발견 가능한 비활성 공유·내보내기 작업**: 아직 사용할 수 없는 공유 링크 생성·내보내기·접근 관리 작업을 포커스 가능한 `aria-disabled` 버튼으로 유지하고, 보이는 선행조건 설명을 연결하며, 시각적 비활성 상태와 포인터·Enter·Space 실행 차단을 함께 보존합니다. 공유 링크 생성은 프로젝트가 없을 때 “먼저 프로젝트를 선택하세요.”라는 다음 행동을 안내하고, 실제 생성 중인 짧은 busy 상태에서는 중복 실행 방지를 위해 native `disabled`를 유지합니다. - **테이블 및 컬럼 편집 기능**: UI 패널을 통해 노드를 선택하고, 테이블의 이름/코멘트를 수정하며, 컬럼을 추가/수정/삭제하거나 테이블을 삭제할 수 있는 기능 추가. - **테스트 추가**: 프론트엔드 테스트 커버리지 100% 목표 달성을 위해 `cardinality.ts`, `types.ts`, `export.ts` 의 미달성 분기 및 함수 테스트 추가 (`cardinality_extra.test.ts` 등). - `.gitignore` 파일에 `coverage/` 폴더를 추가하여 불필요한 테스트 아티팩트가 커밋되지 않도록 보완. diff --git a/frontend/src/accessibility.css b/frontend/src/accessibility.css new file mode 100644 index 000000000..2ba8f4fd9 --- /dev/null +++ b/frontend/src/accessibility.css @@ -0,0 +1,6 @@ +.exportModal button[aria-disabled="true"] { + opacity: 0.6; + cursor: not-allowed; + background-color: var(--color-surface-muted); + color: var(--color-disabled); +} diff --git a/frontend/src/components/modals/ExportModal.test.tsx b/frontend/src/components/modals/ExportModal.test.tsx index bd89c5384..08b7e22f6 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,33 @@ describe('ExportModal', () => { expect(screen.getByRole('button', { name: '생성 중...' })).toBeDisabled(); }); - it('runs each export artifact action from the modal', () => { + it('keeps unavailable share-link creation discoverable and inert until a project is selected', async () => { + const onCreateShareLink = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + const createShareLinkButton = screen.getByRole('button', { name: '링크 만들기' }); + expect(createShareLinkButton).not.toBeDisabled(); + expect(createShareLinkButton).toHaveAttribute('aria-disabled', 'true'); + expect(createShareLinkButton).toHaveAttribute('aria-describedby', 'share-link-create-hint'); + expect(screen.getByText('먼저 프로젝트를 선택하세요.')).toBeVisible(); + + createShareLinkButton.focus(); + expect(createShareLinkButton).toHaveFocus(); + await user.click(createShareLinkButton); + await user.keyboard('{Enter}'); + await user.keyboard(' '); + + expect(onCreateShareLink).not.toHaveBeenCalled(); + }); + + 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 +132,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 +184,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..4e8e7e533 100644 --- a/frontend/src/components/modals/ExportModal.tsx +++ b/frontend/src/components/modals/ExportModal.tsx @@ -69,6 +69,7 @@ export function ExportModal({ : isShareLinkCopied ? '링크가 복사되었습니다. 접근 권한이 있는 팀원이 최신 스냅샷을 열 수 있습니다.' : '선택한 다이어그램을 공유하거나 산출물로 내보낼 준비가 되었습니다.'; + const isShareLinkUnavailable = !canCreateShareLink && !isCreatingShareLink; const artifacts: ExportArtifact[] = [ { @@ -190,19 +191,35 @@ export function ExportModal({ {isShareLinkCopied ? '복사 완료' : '링크 복사'} ) : ( - + <> + + {isShareLinkUnavailable ? ( + + ) : null} + )}