diff --git a/.jules/palette.md b/.jules/palette.md index 0bbf5248..a76bd67f 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -108,10 +108,14 @@ **Learning:** [When an element is removed from the DOM, focus naturally resets to the document body, breaking the keyboard navigation flow. It is critical to calculate the next logical focus target prior to deletion and programmatically restore focus post-render.] **Action:** [In future components involving item deletion within lists or tables, proactively incorporate index calculations before removing items to manage focus restoration correctly.] -## $(date +%Y-%m-%d) - Add Confirmation Dialog for CSV Import +## 2026-09-02 - Add Confirmation Dialog for CSV Import **Learning:** File import actions that completely overwrite existing application state can lead to severe data loss if triggered accidentally. In a WBS planner where users invest significant time building task hierarchies, destructive imports need explicit user confirmation. **Action:** Always add a confirmation dialog (`window.confirm` or custom modal) for any import or sync action that wipes out the current in-memory or persisted state, especially when there's no undo mechanism. -## $(date +%Y-%m-%d) - Prevent accidental data loss in inline editors +## 2026-09-02 - Prevent accidental data loss in inline editors **Learning:** Forms that take a long time to fill out (like a WBS editor) are prone to accidental closure by users pressing `Escape` or clicking cancel. This causes immediate data loss without any warning, resulting in frustration. **Action:** When working on editors that can be dismissed, track whether the user has modified any fields compared to their initial state. If there are changes, intercept the close action and present a confirmation dialog (`window.confirm`) to ensure they really want to discard their edits. Bypass this for intentional saves or explicit data overrides. + +## 2026-09-02 - Preserve Keyboard Focus with aria-disabled on form submit +**Learning:** Using the native `disabled` attribute on a form's submit button based on validation state causes the button to lose focus, which resets the user's keyboard flow and prevents the button from triggering helpful contextual error feedback (like toast notifications). +**Action:** Instead of `disabled`, use `aria-disabled="true"` to signal the disabled state to assistive technologies, style it accordingly, explicitly ensure native `disabled` is false to keep it focusable, and intercept the click/submit event to display a helpful message explaining why submission is blocked. diff --git a/app.js b/app.js index a04aae71..58029fc4 100644 --- a/app.js +++ b/app.js @@ -429,7 +429,15 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { return; } event.preventDefault(); + renderDraftValidation.flush(); + + const saveButton = form.querySelector('button[type="submit"]'); + if (saveButton && saveButton.getAttribute('aria-disabled') === 'true') { + showToast(saveButton.title || '입력값을 올바르게 수정해야 저장할 수 있습니다.'); + return; + } + saveEditor(); }); @@ -790,6 +798,7 @@ function renderEditorRow(anchorId) { panel.className = 'editor-panel'; const form = document.createElement('form'); form.dataset.editorForm = 'true'; + form.noValidate = true; const editorGrid = document.createElement('div'); editorGrid.className = 'editor-grid'; @@ -1068,7 +1077,12 @@ function renderEditorValidation() { const saveButton = form.querySelector('button[type="submit"]'); if (saveButton) { - saveButton.disabled = errors.length > 0; + if (errors.length > 0) { + saveButton.setAttribute('aria-disabled', 'true'); + } else { + saveButton.removeAttribute('aria-disabled'); + } + saveButton.disabled = false; saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)'; } diff --git a/tests/e2e/editor-aria-disabled-mobile.spec.js b/tests/e2e/editor-aria-disabled-mobile.spec.js new file mode 100644 index 00000000..74f2c418 --- /dev/null +++ b/tests/e2e/editor-aria-disabled-mobile.spec.js @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +test.use({ viewport: { width: 375, height: 812 } }); + +test('invalid save feedback remains usable at a narrow mobile viewport', async ({ page }) => { + await page.goto('./'); + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + + const phaseInput = page.locator('[data-testid="editor-phase"]'); + const saveButton = page.getByRole('button', { name: '저장', exact: true }); + + await phaseInput.fill(''); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + expect(await saveButton.evaluate(node => node.disabled)).toBe(false); + await expect(page.locator('.editor-panel')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + await saveButton.focus(); + await expect(saveButton).toBeFocused(); + await saveButton.click(); + + await expect(page.locator('#toast')).toContainText('입력값을 올바르게 수정해야 저장할 수 있습니다.'); + await expect(page.locator('#editor-errors')).toContainText('최상위 작업은 단계 값을 입력해야 합니다.'); + await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(phaseInput).toHaveAttribute('aria-invalid', 'true'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); diff --git a/tests/e2e/editor-aria-disabled.spec.js b/tests/e2e/editor-aria-disabled.spec.js new file mode 100644 index 00000000..0557e50e --- /dev/null +++ b/tests/e2e/editor-aria-disabled.spec.js @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; + +test.describe('inline editor disabled-state feedback', () => { + test.beforeEach(async ({ page }) => { + await page.goto('./'); + }); + + test('routes an invalid required-field submit through application feedback', async ({ page }) => { + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + + const phaseInput = page.locator('[data-testid="editor-phase"]'); + const saveButton = page.getByRole('button', { name: '저장', exact: true }); + + await phaseInput.fill(''); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + const isDisabled = await saveButton.evaluate(n => n.disabled); + expect(isDisabled).toBe(false); + + await saveButton.focus(); + await expect(saveButton).toBeFocused(); + await saveButton.press('Enter'); + + await expect(page.locator('#toast')).toContainText('입력값을 올바르게 수정해야 저장할 수 있습니다.'); + await expect(page.locator('#editor-errors')).toContainText('최상위 작업은 단계 값을 입력해야 합니다.'); + await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(phaseInput).toHaveAttribute('aria-invalid', 'true'); + }); + + test('blocks invalid save via click and preserves editor', async ({ page }) => { + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + + const phaseInput = page.locator('[data-testid="editor-phase"]'); + const saveButton = page.getByRole('button', { name: '저장', exact: true }); + + await phaseInput.fill(''); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + const isDisabled = await saveButton.evaluate(n => n.disabled); + expect(isDisabled).toBe(false); + + // Instead of a Playwright synthetic click, use plain evaluate to sidestep test runner validation bugs + await saveButton.evaluate(n => n.click()); + + await expect(page.locator('#toast')).toContainText('입력값을 올바르게 수정해야 저장할 수 있습니다.'); + await expect(page.locator('#editor-errors')).toContainText('최상위 작업은 단계 값을 입력해야 합니다.'); + await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(phaseInput).toHaveAttribute('aria-invalid', 'true'); + }); + + test('saves a corrected draft on the first immediate submit', async ({ page }) => { + const rows = page.locator('tbody tr[data-task-id]'); + const initialRowCount = await rows.count(); + + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + + const phaseInput = page.locator('[data-testid="editor-phase"]'); + const saveButton = page.getByRole('button', { name: '저장', exact: true }); + + await phaseInput.fill(''); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + + await phaseInput.fill('Immediate valid phase'); + await saveButton.click(); + + await expect(page.locator('.editor-panel')).toHaveCount(0); + await expect(rows).toHaveCount(initialRowCount + 1); + await expect(rows.filter({ hasText: 'Immediate valid phase' })).toHaveCount(1); + }); +}); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..694822be 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -14,7 +14,9 @@ const addTopLevelTask = async (page, values) => { const expectSaveBlockedWith = async (page, message) => { const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + const isDisabled = await saveButton.evaluate(n => n.disabled); + expect(isDisabled).toBe(false); await expect(page.locator('#editor-errors')).toContainText(message); await expect(page.locator('.editor-panel')).toBeVisible(); };