Skip to content
Draft
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
8 changes: 6 additions & 2 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 15 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '입력값을 올바르게 수정해야 저장할 수 있습니다.');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return;
Comment thread
seonghobae marked this conversation as resolved.
}

saveEditor();
});

Expand Down Expand Up @@ -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';

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

Expand Down
27 changes: 27 additions & 0 deletions tests/e2e/editor-aria-disabled-mobile.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
68 changes: 68 additions & 0 deletions tests/e2e/editor-aria-disabled.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 3 additions & 1 deletion tests/e2e/scopeweave.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Expand Down
Loading