-
Notifications
You must be signed in to change notification settings - Fork 0
fix(a11y): synchronize editor validation and submit #468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
seonghobae
wants to merge
17
commits into
develop
Choose a base branch
from
fix/editor-validation-sync-411
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
1d9cd03
fix(a11y): synchronize editor validation and submit
seonghobae cfed01f
test(a11y): cover editor validation semantics in c8
seonghobae c5e5622
merge(a11y): reconcile editor validation with protected develop
seonghobae d4b894e
merge(a11y): reconcile editor validation with current develop
seonghobae 2283a84
test(security): cover fullwidth CSV formula prefixes
seonghobae afc93af
fix(security): neutralize fullwidth CSV formula prefixes
seonghobae 7daf5c1
test(security): assert fullwidth CSV quote boundaries
seonghobae 929649c
merge(develop): reconcile editor validation with toast accessibility
seonghobae 32d92df
merge(develop): reconcile editor validation with OpenCode config
seonghobae 7150ad2
merge(develop): reconcile editor validation with adaptive orchestration
seonghobae 0de787a
Merge branch 'develop' into fix/editor-validation-sync-411
opencode-agent[bot] 0b2de67
chore(stack): reconcile editor validation with current develop
seonghobae d4228f5
chore(scope): keep module preload in dedicated PR
seonghobae 588bdaa
fix(test): preserve preload contract for full browser suite
seonghobae f1350f9
test(security): align CSV fuzz oracle with fullwidth prefixes
seonghobae fca25c7
test(a11y): require invalid save focus preservation
seonghobae 815af81
fix(a11y): keep editor validation in the submit path
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| # Focusable editor validation and synchronous save authority | ||
|
|
||
| ## Decision | ||
|
|
||
| The editor save button remains a native button in the sequential keyboard order. | ||
| When the current draft is invalid, ScopeWeave exposes | ||
| `aria-disabled="true"`, connects the button to `#editor-errors` with | ||
| `aria-describedby`, and keeps the control physically focusable. Activation is | ||
| still accepted as an input event, but `saveEditor()` synchronously validates the | ||
| latest draft and refuses persistence while errors remain. | ||
|
|
||
| The debounced validation pass is presentation only. It updates field error | ||
| states, the error summary, and save-button semantics; it is not an authorization | ||
| or persistence boundary. This avoids two inverse races: | ||
|
|
||
| - a user corrects the final error and immediately clicks or presses Enter before | ||
| the debounce updates a stale disabled state; and | ||
| - a user introduces an error and immediately submits before the presentation | ||
| layer catches up. | ||
|
|
||
| Both paths are decided by the same latest-draft validation inside | ||
| `saveEditor()`. | ||
|
|
||
| ## Accessibility rationale | ||
|
|
||
| WAI-ARIA defines `aria-disabled` as a perceivable disabled state. W3C's | ||
| Authoring Practices notes that disabled commands can remain focusable when their | ||
| discoverability is useful, provided scripting prevents the unavailable action. | ||
| The save action is a primary command whose error relationship benefits from | ||
| keyboard discovery, so ScopeWeave keeps it focusable and exposes the current | ||
| error summary as its accessible description. | ||
|
|
||
| The native `disabled` attribute is not used for this state because it removes the | ||
| button from normal keyboard focus and can preserve a stale block while the | ||
| debounced presentation state catches up. The implementation must not treat | ||
| `aria-disabled` alone as enforcement; synchronous validation prevents mutation. | ||
|
|
||
| ## Executable evidence | ||
|
|
||
| `tests/e2e/editor-validation-sync.spec.js` verifies: | ||
|
|
||
| - an invalid save control remains focusable and described; | ||
| - activating it does not create a task; | ||
| - a draft corrected immediately before click saves without waiting for debounce; | ||
| - a draft corrected immediately before Enter saves without waiting for debounce; | ||
| - a newly invalid draft cannot persist before debounce completes; and | ||
| - error text remains available through `#editor-errors`. | ||
|
|
||
| The existing full-browser suite is updated to activate invalid save controls and | ||
| assert that task count and editor state are unchanged for reversed dates, | ||
| invalid calendar dates, and HTML input. It also re-enables the complete | ||
| `scopeweave.spec.js` cloud path for the editor acceptance boundary. | ||
|
|
||
| ## Compatibility and rollback | ||
|
|
||
| This change does not modify persisted WBS data, API contracts, authentication, | ||
| or server storage. It changes only the editor's presentation semantics and keeps | ||
| existing synchronous validation behavior as the persistence authority. | ||
|
|
||
| Rollback must revert the button-state implementation, focused browser tests, | ||
| full-suite expectations, package script, CHANGELOG entry, and this record | ||
| together. Reintroducing native `disabled` requires a new proof that immediate | ||
| correction cannot be blocked by stale debounced state. | ||
|
|
||
| ## 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. (2025). *Developing a keyboard interface*. | ||
| WAI-ARIA Authoring Practices Guide. | ||
| https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ | ||
|
|
||
| World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines | ||
| (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
seonghobae marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { test, expect } from '@playwright/test'; | ||
|
|
||
| async function openRootEditor(page) { | ||
| await page.getByRole('button', { name: '최상위 작업 추가' }).click(); | ||
| return page.locator('.editor-panel'); | ||
| } | ||
|
|
||
| async function makeSavePresentationValid(page, saveButton) { | ||
| const phaseInput = page.getByTestId('editor-phase'); | ||
| await phaseInput.fill('temporarily valid'); | ||
| await expect(saveButton).not.toHaveAttribute('aria-disabled', 'true'); | ||
| await expect(saveButton).not.toHaveAttribute('aria-describedby', 'editor-errors'); | ||
| return phaseInput; | ||
| } | ||
|
|
||
| async function expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount) { | ||
| await expect(saveButton).toBeFocused(); | ||
| await expect(editor).toBeVisible(); | ||
| await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); | ||
| await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); | ||
| await expect(page.locator('#editor-errors')).toContainText('최상위 작업은 단계 값을 입력해야 합니다.'); | ||
| await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); | ||
| } | ||
|
|
||
| test('invalid pointer save refreshes validation and preserves save focus', async ({ page }) => { | ||
| await page.goto('./'); | ||
| const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); | ||
| const editor = await openRootEditor(page); | ||
| const saveButton = editor.getByRole('button', { name: '저장', exact: true }); | ||
| const phaseInput = await makeSavePresentationValid(page, saveButton); | ||
|
|
||
| await phaseInput.fill(''); | ||
| await saveButton.focus(); | ||
| await expect(saveButton).toBeFocused(); | ||
| await saveButton.click(); | ||
|
|
||
| await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); | ||
| }); | ||
|
|
||
| test('invalid keyboard save refreshes validation and preserves save focus', async ({ page }) => { | ||
| await page.goto('./'); | ||
| const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); | ||
| const editor = await openRootEditor(page); | ||
| const saveButton = editor.getByRole('button', { name: '저장', exact: true }); | ||
| const phaseInput = await makeSavePresentationValid(page, saveButton); | ||
|
|
||
| await phaseInput.fill(''); | ||
| await saveButton.focus(); | ||
| await expect(saveButton).toBeFocused(); | ||
| await saveButton.press('Enter'); | ||
|
|
||
| await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); | ||
| }); | ||
|
|
||
| test('latest valid draft saves immediately by click and Enter', async ({ page }) => { | ||
| await page.goto('./'); | ||
| const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); | ||
|
|
||
| let editor = await openRootEditor(page); | ||
| let phaseInput = page.getByTestId('editor-phase'); | ||
| let saveButton = editor.getByRole('button', { name: '저장', exact: true }); | ||
| await phaseInput.fill('P9000.즉시 클릭'); | ||
| await saveButton.click(); | ||
|
|
||
| await expect(editor).toHaveCount(0); | ||
| await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount + 1); | ||
|
|
||
| editor = await openRootEditor(page); | ||
| phaseInput = page.getByTestId('editor-phase'); | ||
| await phaseInput.fill('P9001.즉시 엔터'); | ||
| await phaseInput.press('Enter'); | ||
|
|
||
| await expect(editor).toHaveCount(0); | ||
| await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount + 2); | ||
| }); | ||
|
|
||
| test('latest invalid draft cannot save before debounce completes', async ({ page }) => { | ||
| await page.goto('./'); | ||
| const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); | ||
| const editor = await openRootEditor(page); | ||
| const saveButton = editor.getByRole('button', { name: '저장', exact: true }); | ||
| const phaseInput = await makeSavePresentationValid(page, saveButton); | ||
|
|
||
| await phaseInput.fill(''); | ||
| await saveButton.focus(); | ||
| await saveButton.click(); | ||
|
|
||
| await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.