diff --git a/.jules/palette.md b/.jules/palette.md index 0bbf5248..596c1d0c 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -78,9 +78,9 @@ **Learning:** Consistently adding `title` attributes with keyboard shortcut hints to primary actions (e.g., `(Enter)`) and cancel actions (e.g., `(Esc)`) improves discoverability, but `title` alone is not reliably exposed to keyboard and screen-reader users. **Action:** Include keyboard shortcut hints in `title` attributes and pair them with `aria-keyshortcuts` for primary and cancel buttons when implementing or updating forms and dialogs. -## 2026-06-27 - Replace native disabled with aria-disabled for action buttons -**Learning:** Native `disabled` attributes swallow all DOM events, including clicks, and prevent focus. This breaks keyboard accessibility because users tabbing through the page skip the element entirely, and it prevents click handlers from showing helpful toast messages explaining why an action is unavailable. -**Action:** Use `aria-disabled="true"` instead of `disabled` for interactive buttons when the UI should preserve focusability or show inline feedback. Control visual presentation with `[aria-disabled="true"]` in CSS and guard the click handler by checking `getAttribute('aria-disabled') === 'true'` before preventing the action and showing feedback. +## 2026-06-27 - Choose native disabled vs aria-disabled based on explanation delivery +**Learning:** Native `disabled` removes a button from focus and suppresses normal pointer/click interaction; `aria-disabled="true"` preserves interaction semantics so guarded handlers can explain why an action is unavailable. Neither pattern is universally preferable: the choice depends on whether the blocked reason and recovery action remain independently perceivable. +**Action:** Use native `disabled` together with `aria-disabled="true"` when the action must leave the tab order and the reason/recovery step is exposed through persistent nearby content. Use `aria-disabled="true"` without native `disabled` when the control itself must remain focusable/clickable to provide guarded feedback. In both cases, never rely on `title` alone for the explanation. ## 2026-06-28 - Keyboard Shortcut Hints **Learning:** Keyboard shortcut hints are more useful when they are discoverable to both mouse and assistive-technology users; `title` alone is hover-driven and unreliable for screen readers. @@ -115,3 +115,7 @@ ## $(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-25 - [접근성] 네이티브 disabled에는 독립적인 설명 경로가 필요함 +**Learning:** `aria-disabled="true"`와 네이티브 `disabled`를 함께 사용하면 비활성화 상태를 명확하게 전달하고 버튼을 탭 순서와 일반 클릭 경로에서 제외할 수 있습니다. 그러나 네이티브 `disabled`는 버튼 자체의 `title`/클릭 피드백을 신뢰할 수 없게 만들므로, 사용자가 비활성화 이유와 복구 방법을 다른 경로로 확인할 수 있어야 합니다. +**Action:** 네이티브 `disabled`를 사용하는 경우 `aria-disabled`도 동기화하고, 버튼과 `aria-describedby`로 연결된 지속적으로 보이는 설명에 비활성화 이유와 다음 행동을 제공합니다. 버튼 자체의 `title` 또는 차단된 클릭 토스트만을 설명 경로로 사용하지 않습니다. diff --git a/app.js b/app.js index a04aae71..72c49c6c 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,7 @@ const STORAGE_KEY = 'scopeweave:planner-state:v1'; const DEFAULT_PROJECT_NAME = 'ScopeWeave Planner'; const MAX_PROJECT_NAME_LENGTH = 120; const MAX_BASE_DATE_LENGTH = 10; +const TASK_DEPENDENT_ACTIONS_UNAVAILABLE_MESSAGE = '작업이 없어 CSV 내보내기와 간트차트를 사용할 수 없습니다. 최상위 작업을 추가하거나 CSV를 가져오세요.'; const OWNER_COLORS = [ '#3f51b5', '#8e24aa', '#d81b60', '#ef6c00', '#6d4c41', '#00897b', '#1e88e5', '#3949ab', '#7cb342', '#f4511e', @@ -224,7 +225,8 @@ const elements = { closeGanttButton: document.getElementById('close-gantt'), connectJsonSyncButton: document.getElementById('connect-json-sync'), syncStatus: document.getElementById('sync-status'), - toast: document.getElementById('toast') + toast: document.getElementById('toast'), + taskDependentActionsStatus: document.getElementById('task-dependent-actions-status') }; async function bootstrap() { @@ -303,24 +305,10 @@ function bindHeaderEvents(persistAndRenderMetadata) { elements.baseDateInput.addEventListener('blur', persistAndRenderMetadata.flush); elements.addRootButton.addEventListener('click', () => openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() })); - elements.exportCsvButton.addEventListener('click', (e) => { - if (elements.exportCsvButton.getAttribute('aria-disabled') === 'true') { - e.preventDefault(); - showToast('내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'); - return; - } - exportCsv(); - }); + elements.exportCsvButton.addEventListener('click', exportCsv); elements.importCsvButton.addEventListener('click', () => elements.csvFileInput.click()); elements.csvFileInput.addEventListener('change', handleCsvImport); - elements.openGanttButton.addEventListener('click', (e) => { - if (elements.openGanttButton.getAttribute('aria-disabled') === 'true') { - e.preventDefault(); - showToast('간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'); - return; - } - openGanttModal(); - }); + elements.openGanttButton.addEventListener('click', openGanttModal); elements.closeGanttButton.addEventListener('click', closeGanttModal); elements.ganttModal.addEventListener('click', (event) => { if (event.target.dataset.closeModal === 'true') { @@ -530,15 +518,26 @@ function renderAll() { const rows = []; const hasTasks = state.tasks.length > 0; + const taskDependentActionsStatus = hasTasks ? '' : TASK_DEPENDENT_ACTIONS_UNAVAILABLE_MESSAGE; + if (elements.taskDependentActionsStatus.textContent !== taskDependentActionsStatus) { + elements.taskDependentActionsStatus.textContent = taskDependentActionsStatus; + } + if (!hasTasks) { elements.exportCsvButton.setAttribute('aria-disabled', 'true'); elements.openGanttButton.setAttribute('aria-disabled', 'true'); + elements.exportCsvButton.disabled = true; + elements.openGanttButton.disabled = true; + elements.exportCsvButton.setAttribute('aria-describedby', 'task-dependent-actions-help'); + elements.openGanttButton.setAttribute('aria-describedby', 'task-dependent-actions-help'); } else { elements.exportCsvButton.removeAttribute('aria-disabled'); elements.openGanttButton.removeAttribute('aria-disabled'); + elements.exportCsvButton.disabled = false; + elements.openGanttButton.disabled = false; + elements.exportCsvButton.removeAttribute('aria-describedby'); + elements.openGanttButton.removeAttribute('aria-describedby'); } - elements.exportCsvButton.title = hasTasks ? '' : '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'; - elements.openGanttButton.title = hasTasks ? '' : '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'; // ⚡ Bolt: Cache parent IDs to convert O(N^2) render loop to O(N) cachedHasChildrenSet.clear(); diff --git a/index.html b/index.html index d24b2a88..7ac17a10 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + @@ -88,9 +90,11 @@

ScopeWeave Planner

diff --git a/styles.css b/styles.css index 9d715f00..ea721719 100644 --- a/styles.css +++ b/styles.css @@ -559,6 +559,16 @@ select[data-inline-progress]:focus { margin-top: auto; } +#task-dependent-actions-help { + display: none; + flex-basis: 100%; + margin: 0; +} + +#open-gantt[aria-disabled="true"] + #task-dependent-actions-help { + display: block; +} + .toast { position: fixed; right: 32px; diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..0df110d3 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -183,10 +183,14 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('.table-empty')).toContainText('등록된 작업이 없습니다'); await expect(page.locator('.table-empty').getByRole('button', { name: '최상위 작업 추가' })).toBeVisible(); await expect(page.locator('.table-empty').getByRole('button', { name: 'CSV 가져오기' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toBeDisabled(); await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toHaveAttribute('aria-disabled', 'true'); - await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toHaveAttribute('title', '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'); + await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toHaveAttribute('aria-describedby', 'task-dependent-actions-help'); + await expect(page.locator('#task-dependent-actions-help')).toBeVisible(); + await expect(page.locator('#task-dependent-actions-help')).toContainText('작업을 추가하거나 CSV를 가져오세요'); + await expect(page.getByRole('button', { name: '간트차트보기' })).toBeDisabled(); await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('aria-disabled', 'true'); - await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('title', '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'); + await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('aria-describedby', 'task-dependent-actions-help'); }); test('keeps the empty WBS state inside the mobile table viewport', async ({ page }) => { diff --git a/tests/e2e/toast-accessibility.spec.js b/tests/e2e/toast-accessibility.spec.js index 5e45cb79..36bc4736 100644 --- a/tests/e2e/toast-accessibility.spec.js +++ b/tests/e2e/toast-accessibility.spec.js @@ -14,7 +14,7 @@ test('cloud status feedback is visibly rendered as a non-focus-taking live statu await expect.poll( () => toast.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)), - { message: 'toast opacity should reach its fully visible transition state' }, + { message: 'advisory status must reach its fully visible transition state' }, ).toBeGreaterThanOrEqual(0.99); await expect.poll( @@ -22,3 +22,95 @@ test('cloud status feedback is visibly rendered as a non-focus-taking live statu { message: 'advisory status must not capture keyboard focus' }, ).toBe(false); }); + +test('disabled empty-state actions expose and announce a reason plus next action', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Empty Scope', + baseDate: '2026-04-20', + tasks: [], + })); + }); + await page.goto('/'); + + const exportButton = page.getByRole('button', { name: 'CSV 내보내기' }); + const ganttButton = page.getByRole('button', { name: '간트차트보기' }); + const help = page.locator('#task-dependent-actions-help'); + const status = page.locator('#task-dependent-actions-status'); + + await expect(exportButton).toBeDisabled(); + await expect(ganttButton).toBeDisabled(); + await expect(exportButton).toHaveAttribute('aria-disabled', 'true'); + await expect(ganttButton).toHaveAttribute('aria-disabled', 'true'); + await expect(exportButton).toHaveAttribute('aria-describedby', 'task-dependent-actions-help'); + await expect(ganttButton).toHaveAttribute('aria-describedby', 'task-dependent-actions-help'); + await expect(help).not.toHaveAttribute('role', 'status'); + await expect(help).toBeVisible(); + await expect(help).toContainText('작업을 추가하거나 CSV를 가져오세요'); + const emptyState = page.locator('.table-empty'); + const emptyStateBox = await emptyState.boundingBox(); + const actionBarBox = await page.locator('.bottom-action-bar').boundingBox(); + expect(emptyStateBox).not.toBeNull(); + expect(actionBarBox).not.toBeNull(); + expect(emptyStateBox.y + emptyStateBox.height).toBeLessThanOrEqual(actionBarBox.y); + await expect(status).toHaveAttribute('role', 'status'); + await expect(status).toHaveAttribute('aria-live', 'polite'); + await expect(status).toHaveAttribute('aria-atomic', 'true'); + await expect(status).toHaveClass(/\bsr-only\b/); + await expect(status).toHaveText('작업이 없어 CSV 내보내기와 간트차트를 사용할 수 없습니다. 최상위 작업을 추가하거나 CSV를 가져오세요.'); +}); + +test('task-dependent help disappears and detaches once the actions are available', async ({ page }) => { + await page.goto('/'); + + const exportButton = page.getByRole('button', { name: 'CSV 내보내기' }); + const ganttButton = page.getByRole('button', { name: '간트차트보기' }); + const help = page.locator('#task-dependent-actions-help'); + const status = page.locator('#task-dependent-actions-status'); + + await expect(page.locator('tbody tr[data-task-id]')).not.toHaveCount(0); + await expect(exportButton).toBeEnabled(); + await expect(ganttButton).toBeEnabled(); + await expect(exportButton).not.toHaveAttribute('aria-disabled', 'true'); + await expect(ganttButton).not.toHaveAttribute('aria-disabled', 'true'); + await expect(exportButton).not.toHaveAttribute('aria-describedby', 'task-dependent-actions-help'); + await expect(ganttButton).not.toHaveAttribute('aria-describedby', 'task-dependent-actions-help'); + await expect(help).toBeHidden(); + await expect(status).toHaveText(''); +}); + +test('the last task becoming unavailable is announced through an always-present live region', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'One Task Scope', + baseDate: '2026-04-20', + tasks: [{ + id: 'task-only', + parentId: null, + depth: 1, + expanded: true, + isSynthetic: false, + phase: '검증 단계', + activity: '', + task: '', + actualProgressStatus: '미착수(0%)', + }], + })); + }); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/'); + + const status = page.locator('#task-dependent-actions-status'); + await expect(status).toHaveAttribute('role', 'status'); + await expect(status).toHaveAttribute('aria-live', 'polite'); + await expect(status).toHaveAttribute('aria-atomic', 'true'); + await expect(status).toHaveText(''); + + await page.getByRole('button', { name: /삭제 - 검증 단계/ }).click(); + + await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toBeDisabled(); + await expect(page.getByRole('button', { name: '간트차트보기' })).toBeDisabled(); + await expect(status).toHaveText('작업이 없어 CSV 내보내기와 간트차트를 사용할 수 없습니다. 최상위 작업을 추가하거나 CSV를 가져오세요.'); + await expect(page.locator('#task-dependent-actions-help')).toBeVisible(); + await expect(page.locator('#add-root-task')).toBeFocused(); +}); diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..26854d96 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); +const stylesCss = readFileSync(new URL('../../styles.css', import.meta.url), 'utf8'); +const appJs = readFileSync(new URL('../../app.js', import.meta.url), 'utf8'); const toastStateCss = readFileSync(new URL('../../toast-state.css', import.meta.url), 'utf8'); const cloudSyncJs = readFileSync(new URL('../../cloud-sync.js', import.meta.url), 'utf8'); @@ -18,6 +20,25 @@ function syncStatusElementMarkup(html) { return match[0]; } +function buttonElementMarkup(html, id) { + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = html.match(new RegExp(`]*\\bid=["']${escapedId}["'][^>]*>`, 'i')); + assert.ok(match, `production index.html contains #${id}`); + return match[0]; +} + +function taskHelpElementMarkup(html) { + const match = html.match(/]*\bid=["']task-dependent-actions-help["'][^>]*>/i); + assert.ok(match, 'production index.html contains task-dependent action help'); + return match[0]; +} + +function taskStatusElementMarkup(html) { + const match = html.match(/]*\bid=["']task-dependent-actions-status["'][^>]*>/i); + assert.ok(match, 'production index.html contains the persistent task-dependent live region'); + return match[0]; +} + test('toast container exposes advisory status updates without taking focus', () => { const toast = toastElementMarkup(indexHtml); assert.match(toast, /\brole=["']status["']/i, 'toast uses the WAI-ARIA status role'); @@ -62,3 +83,92 @@ test('cloud toast state is visibly rendered by a shipped stylesheet', () => { 'the shipped cloud toast state becomes visually observable', ); }); + +test('task-dependent help is visible only while native actions are unavailable and live updates stay in-tree', () => { + const exportButton = buttonElementMarkup(indexHtml, 'export-csv'); + const ganttButton = buttonElementMarkup(indexHtml, 'open-gantt'); + const help = taskHelpElementMarkup(indexHtml); + const status = taskStatusElementMarkup(indexHtml); + + assert.match(exportButton, /\bdisabled(?:\s|=|>)/i, 'export starts disabled until state hydration completes'); + assert.match(ganttButton, /\bdisabled(?:\s|=|>)/i, 'Gantt starts disabled until state hydration completes'); + assert.match(exportButton, /\baria-disabled=["']true["']/i, 'export starts with synchronized unavailable semantics'); + assert.match(ganttButton, /\baria-disabled=["']true["']/i, 'Gantt starts with synchronized unavailable semantics'); + assert.match(exportButton, /\baria-describedby=["']task-dependent-actions-help["']/i, 'export starts linked to its reason'); + assert.match(ganttButton, /\baria-describedby=["']task-dependent-actions-help["']/i, 'Gantt starts linked to its reason'); + assert.doesNotMatch(help, /\brole=["']status["']/i, 'the conditionally hidden visible helper is not itself a live region'); + assert.match(status, /\bclass=["'][^"']*\bsr-only\b[^"']*["']/i, 'the live region stays visually hidden without leaving the accessibility tree'); + assert.match(status, /\brole=["']status["']/i, 'availability changes use a dedicated WAI-ARIA status region'); + assert.match(status, /\baria-live=["']polite["']/i, 'availability changes are announced politely'); + assert.match(status, /\baria-atomic=["']true["']/i, 'the complete reason and recovery action are announced'); + assert.match( + stylesCss, + /#task-dependent-actions-help\s*\{[^}]*\bdisplay\s*:\s*none\s*;[^}]*\}/s, + 'the visible unavailable-state explanation is hidden by default in the shipped stylesheet', + ); + assert.match( + stylesCss, + /#open-gantt\[aria-disabled=["']true["']\]\s*\+\s*#task-dependent-actions-help\s*\{[^}]*\bdisplay\s*:\s*block\s*;[^}]*\}/s, + 'the shipped stylesheet shows the visible explanation only when the task-dependent actions are disabled', + ); + assert.doesNotMatch( + indexHtml, + / { + assert.match( + appJs, + /exportCsvButton\.addEventListener\(["']click["'],\s*exportCsv\)/, + 'export uses its direct action handler because native disabled blocks unavailable clicks', + ); + assert.match( + appJs, + /openGanttButton\.addEventListener\(["']click["'],\s*openGanttModal\)/, + 'Gantt uses its direct action handler because native disabled blocks unavailable clicks', + ); + assert.doesNotMatch( + appJs, + /exportCsvButton\.getAttribute\(["']aria-disabled["']\)/, + 'export no longer carries an unreachable disabled-click branch', + ); + assert.doesNotMatch( + appJs, + /openGanttButton\.getAttribute\(["']aria-disabled["']\)/, + 'Gantt no longer carries an unreachable disabled-click branch', + ); + assert.doesNotMatch(appJs, /exportCsvButton\.title\s*=/, 'export no longer relies on a tooltip that native disabled suppresses'); + assert.doesNotMatch(appJs, /openGanttButton\.title\s*=/, 'Gantt no longer relies on a tooltip that native disabled suppresses'); +}); diff --git a/trigger.sh b/trigger.sh new file mode 100644 index 00000000..9b58adb5 --- /dev/null +++ b/trigger.sh @@ -0,0 +1 @@ +git commit --allow-empty -m "Acknowledge code review"