From 1fdc72d8b26434c9d68ed7cbe10cdee84722f147 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:10:23 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Improve=20editor=20va?= =?UTF-8?q?lidation=20UX=20with=20aria-disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the native disabled attribute on the editor save button with aria-disabled="true" to preserve keyboard focusability and interaction for screen readers. The form and button click handlers have been updated to intercept actions when the button is aria-disabled, showing a toast message explaining that validation errors must be fixed first. Mobile UI end-to-end tests were added to ensure proper coverage of these new edge cases. --- .jules/palette.md | 3 +++ app.js | 22 +++++++++++++++++- tests/e2e/test-toast.spec.js | 23 +++++++++++++++++++ tests/e2e/test-validation.spec.js | 38 +++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/test-toast.spec.js create mode 100644 tests/e2e/test-validation.spec.js 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(); +});