diff --git a/.jules/palette.md b/.jules/palette.md index 0bbf5248..8d8d7f8a 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -115,3 +115,6 @@ ## $(date +%Y-%m-%d) - 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-08-29 - Toast fallback for aria-disabled submit buttons +**Learning:** Removing the native `disabled` attribute from the editor's save button means click and submit events are fired when the form is invalid. Because the event handlers intercept the action correctly to maintain UX safety, we must also show a toast message explaining *why* the save failed, providing necessary context for both screen reader and mobile users. +**Action:** When converting native disabled submit buttons to `aria-disabled`, capture and intercept the `submit` and `click` event listeners for the button. Always run `event.preventDefault()` when `aria-disabled="true"` is present on the triggering button and present the button's `title` (or fallback string) via a visible `showToast()` to inform the user of the current constraint. diff --git a/app.js b/app.js index a04aae71..e0d81575 100644 --- a/app.js +++ b/app.js @@ -384,12 +384,20 @@ function bindGlobalEvents() { function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { elements.tableBody.addEventListener('click', (event) => { + const submitBtn = event.target.closest('button[type="submit"]'); + if (submitBtn && submitBtn.getAttribute('aria-disabled') === 'true') { + event.preventDefault(); + showToast(submitBtn.title || '입력값을 올바르게 수정해야 저장할 수 있습니다.'); + return; + } + const row = event.target.closest('tr[data-task-id]'); if (!row) { return; } const taskId = row.dataset.taskId; + const actionButton = event.target.closest('[data-action]'); if (actionButton) { if (actionButton.getAttribute('aria-disabled') === 'true') { @@ -428,6 +436,12 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { if (!form) { return; } + const saveButton = form.querySelector('button[type="submit"]'); + if (saveButton && saveButton.getAttribute('aria-disabled') === 'true') { + event.preventDefault(); + showToast(saveButton.title || '입력값을 올바르게 수정해야 저장할 수 있습니다.'); + return; + } event.preventDefault(); renderDraftValidation.flush(); saveEditor(); @@ -1068,7 +1082,13 @@ 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'); + saveButton.removeAttribute('disabled'); + } else { + saveButton.removeAttribute('aria-disabled'); + saveButton.removeAttribute('disabled'); + } saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)'; } diff --git a/tests/e2e/test-toast.spec.js b/tests/e2e/test-toast.spec.js new file mode 100644 index 00000000..b61ba770 --- /dev/null +++ b/tests/e2e/test-toast.spec.js @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; + +test.use({ viewport: { width: 375, height: 667 }, isMobile: true, hasTouch: true }); +test('Clicking disabled save button should show a toast message (Mobile)', async ({ page }) => { + await page.goto('./'); + + // Click on "최상위 작업 추가" to open editor + await page.click('#add-root-task'); + + // Verify the form is present + const form = page.locator('form[data-editor-form="true"]'); + await expect(form).toBeVisible(); + + // Find the save button + const saveButton = form.locator('button[type="submit"]'); + + // Submit the form + await saveButton.click({ force: true }); + + // Expect toast message to appear + const toast = page.locator('#toast'); + await expect(toast).toContainText('입력값을 올바르게 수정해야 저장할 수 있습니다.'); +}); diff --git a/tests/e2e/test-validation.spec.js b/tests/e2e/test-validation.spec.js new file mode 100644 index 00000000..841c2f47 --- /dev/null +++ b/tests/e2e/test-validation.spec.js @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; + +test.use({ viewport: { width: 375, height: 667 }, isMobile: true, hasTouch: true }); +test('Validation should aria-disable save button instead of native disabled (Mobile)', async ({ page }) => { + await page.goto('./'); + + // Click on "최상위 작업 추가" to open editor + await page.click('#add-root-task'); + + // Verify the form is present + const form = page.locator('form[data-editor-form="true"]'); + await expect(form).toBeVisible(); + + // Find the save button + const saveButton = form.locator('button[type="submit"]'); + + // Since the form is empty, it should be invalid and aria-disabled + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).not.toHaveAttribute('disabled', ''); + + // Submit the form + await saveButton.click({ force: true }); + + // Expect form to still be visible (click should be prevented) + await expect(form).toBeVisible(); + + // Fill in the required field to make it valid + await page.fill('input[data-editor-field="phase"]', 'Test Phase'); + + // Verify it is no longer aria-disabled + await expect(saveButton).not.toHaveAttribute('aria-disabled', 'true'); + + // Submit the form + await saveButton.click(); + + // Verify form is closed + await expect(form).toBeHidden(); +});