Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 21 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || 'μž…λ ₯값을 μ˜¬λ°”λ₯΄κ²Œ μˆ˜μ •ν•΄μ•Ό μ €μž₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€.');
Comment on lines +387 to +390

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 Fresh corrections trigger false warning

After the last correction, submitBtn.getAttribute() can read stale debounced state and block a valid save. Users receive an incorrect validation warning.

Prompt for agents
The editor draft updates synchronously on input, but renderEditorValidation is debounced by 150 ms. Both the delegated click guard at app.js bindTableEvents and the submit guard decide from the button's aria-disabled attribute before flushing pending validation. A user who corrects the last error and immediately saves is blocked using stale state; the reverse transition can bypass the toast. Flush pending validation before either guard reads aria-disabled, or derive the guard directly from validateDraft(state.editor.draft, state.editor.depth), while preserving saveEditor as the final validation boundary.
Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

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') {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)';
}

Expand Down
23 changes: 23 additions & 0 deletions tests/e2e/test-toast.spec.js
Original file line number Diff line number Diff line change
@@ -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('μž…λ ₯값을 μ˜¬λ°”λ₯΄κ²Œ μˆ˜μ •ν•΄μ•Ό μ €μž₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€.');
});
38 changes: 38 additions & 0 deletions tests/e2e/test-validation.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
Loading