From 33bf6772ba4c12dd159e51f259d3ca2e6300db31 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:04:11 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 버튼의 기본 disabled 속성을 aria-disabled로 교체하여 탭 포커스 유지 - 유효성 검사 실패 시 토스트 메시지를 통해 명확한 에러 피드백 제공 - CSS의 :not(:disabled):not([aria-disabled="true"])와 연동되어 시각적 비활성화 상태 유지 - 스크린 리더 사용자가 비활성화된 이유를 명확히 인지할 수 있도록 개선 --- .jules/palette.md | 4 ++++ app.js | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.jules/palette.md b/.jules/palette.md index 0bbf5248..000771d2 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -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. + +## $(date +%Y-%m-%d) - Add Actionable Feedback for aria-disabled Form Submissions +**Learning:** Replacing native `disabled` with `aria-disabled` allows form inputs (like buttons) to remain focusable. However, if a user attempts to submit a form via an `aria-disabled` button without additional feedback, they may be confused as to why the form is not submitting, since the button intercepts the click/enter key but silently fails. +**Action:** When using `aria-disabled` on submit buttons, ensure that intercepting the submit action (e.g., in a `saveEditor` function) provides immediate, actionable feedback (like a toast notification) explaining why the submission was blocked. diff --git a/app.js b/app.js index a04aae71..b3d6e0a4 100644 --- a/app.js +++ b/app.js @@ -1068,7 +1068,12 @@ function renderEditorValidation() { const saveButton = form.querySelector('button[type="submit"]'); if (saveButton) { - saveButton.disabled = errors.length > 0; + saveButton.disabled = false; + if (errors.length > 0) { + saveButton.setAttribute('aria-disabled', 'true'); + } else { + saveButton.removeAttribute('aria-disabled'); + } saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)'; } @@ -1237,6 +1242,7 @@ function saveEditor() { if (errors.length > 0) { state.editor.errors = errors; renderEditorValidation(); + showToast('입력값을 올바르게 수정해야 저장할 수 있습니다.'); return; } From 5595a1970746cecc8ac3c330f31c0c7fb1e30922 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:27:33 +0000 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 버튼의 기본 disabled 속성을 aria-disabled로 교체하여 탭 포커스 유지 - 유효성 검사 실패 시 토스트 메시지를 통해 명확한 에러 피드백 제공 - CSS의 :not(:disabled):not([aria-disabled="true"])와 연동되어 시각적 비활성화 상태 유지 - 스크린 리더 사용자가 비활성화된 이유를 명확히 인지할 수 있도록 개선 From 6c385023ccd1c7e8e5be31af69268434c42a42f0 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:39:53 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 버튼의 기본 disabled 속성을 확실히 해제하고 aria-disabled로 교체하여 탭 포커스 유지 - 유효성 검사 실패 시 토스트 메시지를 통해 명확한 에러 피드백 제공 - CSS의 :not(:disabled):not([aria-disabled="true"])와 연동되어 시각적 비활성화 상태 유지 - 스크린 리더 사용자가 비활성화된 이유를 명확히 인지할 수 있도록 개선 From 0c51408719c8ca37d1b6b3411c254d6fc3b1e01b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:08:37 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 버튼의 기본 disabled 속성을 확실히 해제하고 aria-disabled로 교체하여 탭 포커스 유지 - 유효성 검사 실패 시 토스트 메시지를 통해 명확한 에러 피드백 제공 - CSS의 :not(:disabled):not([aria-disabled="true"])와 연동되어 시각적 비활성화 상태 유지 - 스크린 리더 사용자가 비활성화된 이유를 명확히 인지할 수 있도록 개선 From 828b96c724c0c616a088409795c255dcfd7ffdb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 20:39:09 +0900 Subject: [PATCH 5/8] fix(a11y): let empty required fields reach the invalid-save toast The editor form's native `required` attributes silently blocked the `submit` event before saveEditor()/validateDraft() ran, so clicking Save (or pressing Enter) on an empty required field showed no feedback at all -- the aria-disabled save button gave no explanation. Set form.noValidate so the existing custom validation path always runs; required/aria-required stay for screen-reader semantics. Also update editor tests for the aria-disabled (not native disabled) save button and add a regression test for the click-through case. Co-Authored-By: Claude Sonnet 5 --- .jules/palette.md | 2 +- app.js | 1 + tests/e2e/scopeweave.spec.js | 21 +++++++++++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index 000771d2..ad9ce226 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -116,6 +116,6 @@ **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. -## $(date +%Y-%m-%d) - Add Actionable Feedback for aria-disabled Form Submissions +## 2026-08-31 - Add Actionable Feedback for aria-disabled Form Submissions **Learning:** Replacing native `disabled` with `aria-disabled` allows form inputs (like buttons) to remain focusable. However, if a user attempts to submit a form via an `aria-disabled` button without additional feedback, they may be confused as to why the form is not submitting, since the button intercepts the click/enter key but silently fails. **Action:** When using `aria-disabled` on submit buttons, ensure that intercepting the submit action (e.g., in a `saveEditor` function) provides immediate, actionable feedback (like a toast notification) explaining why the submission was blocked. diff --git a/app.js b/app.js index b3d6e0a4..4c3f3518 100644 --- a/app.js +++ b/app.js @@ -790,6 +790,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'; diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..55430aa0 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -14,7 +14,7 @@ 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'); await expect(page.locator('#editor-errors')).toContainText(message); await expect(page.locator('.editor-panel')).toBeVisible(); }; @@ -703,7 +703,7 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-planned-end"]').fill('2026-05-19'); const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); await expect(page.locator('[data-testid="editor-planned-end"]')).toHaveAttribute('aria-invalid', 'true'); await expect(page.locator('#editor-errors')).toContainText('계획종료일은 계획시작일보다 빠를 수 없습니다'); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); @@ -720,7 +720,7 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-planned-start"]').fill('2026-02-31'); const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); await expect(page.locator('[data-testid="editor-planned-start"]')).toHaveAttribute('aria-invalid', 'true'); await expect(page.locator('#editor-errors')).toContainText('계획시작일은 YYYY-MM-DD 형식의 실제 달력 날짜여야 합니다'); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); @@ -733,7 +733,7 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-task"]').fill(''); const saveButton = page.locator('.editor-panel').getByRole('button', { name: '저장' }); - await expect(saveButton).toBeDisabled(); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); await expect(page.locator('[data-testid="editor-task"]')).toHaveAttribute('aria-invalid', 'true'); await expect(page.locator('#editor-errors')).toContainText('HTML 태그 문자를 사용할 수 없습니다'); await expect(page.locator('.editor-panel')).toBeVisible(); @@ -799,6 +799,19 @@ test.describe('ScopeWeave Planner', () => { await expectSaveBlockedWith(page, '최상위 작업은 단계 값을 입력해야 합니다.'); }); + test('shows the invalid-save toast when clicking Save with an empty required field', async ({ page }) => { + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + await page.locator('[data-testid="editor-phase"]').fill(''); + // The native `required` attribute must not silently block the submit event + // before validateDraft/saveEditor run (form.noValidate = true). The button + // is aria-disabled (not natively disabled) so it stays real-world clickable; + // force the click since Playwright's actionability check treats + // aria-disabled as non-actionable. + await page.getByRole('button', { name: '저장', exact: true }).click({ force: true }); + await expect(page.locator('#toast')).toContainText('입력값을 올바르게 수정해야 저장할 수 있습니다.'); + await expect(page.locator('.editor-panel')).toBeVisible(); + }); + test('rejects saving a depth 2 task without an activity', async ({ page }) => { await page.locator('tbody tr[data-task-id]').first().getByRole('button', { name: '하위 추가' }).click(); await page.locator('[data-testid="editor-activity"]').fill(''); From 9a6f03087940a55f25b9db44fe29fafd62cb8a00 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:29:24 +0000 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 버튼의 기본 disabled 속성을 확실히 해제하고 aria-disabled로 교체하여 탭 포커스 유지 - 유효성 검사 실패 시 토스트 메시지를 통해 명확한 에러 피드백 제공 - CSS의 :not(:disabled):not([aria-disabled="true"])와 연동되어 시각적 비활성화 상태 유지 - 스크린 리더 사용자가 비활성화된 이유를 명확히 인지할 수 있도록 개선 --- .jules/palette.md | 6 +++--- app.js | 1 - tests/e2e/scopeweave.spec.js | 21 ++++----------------- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index ad9ce226..b01e606d 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -108,14 +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 +## 2024-05-31 - 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 +## 2024-05-31 - 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-31 - Add Actionable Feedback for aria-disabled Form Submissions +## 2024-05-31 - Add Actionable Feedback for aria-disabled Form Submissions **Learning:** Replacing native `disabled` with `aria-disabled` allows form inputs (like buttons) to remain focusable. However, if a user attempts to submit a form via an `aria-disabled` button without additional feedback, they may be confused as to why the form is not submitting, since the button intercepts the click/enter key but silently fails. **Action:** When using `aria-disabled` on submit buttons, ensure that intercepting the submit action (e.g., in a `saveEditor` function) provides immediate, actionable feedback (like a toast notification) explaining why the submission was blocked. diff --git a/app.js b/app.js index 4c3f3518..b3d6e0a4 100644 --- a/app.js +++ b/app.js @@ -790,7 +790,6 @@ 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'; diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 55430aa0..dc0cda8d 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -14,7 +14,7 @@ const addTopLevelTask = async (page, values) => { const expectSaveBlockedWith = async (page, message) => { const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toBeDisabled(); await expect(page.locator('#editor-errors')).toContainText(message); await expect(page.locator('.editor-panel')).toBeVisible(); }; @@ -703,7 +703,7 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-planned-end"]').fill('2026-05-19'); const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toBeDisabled(); await expect(page.locator('[data-testid="editor-planned-end"]')).toHaveAttribute('aria-invalid', 'true'); await expect(page.locator('#editor-errors')).toContainText('계획종료일은 계획시작일보다 빠를 수 없습니다'); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); @@ -720,7 +720,7 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-planned-start"]').fill('2026-02-31'); const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toBeDisabled(); await expect(page.locator('[data-testid="editor-planned-start"]')).toHaveAttribute('aria-invalid', 'true'); await expect(page.locator('#editor-errors')).toContainText('계획시작일은 YYYY-MM-DD 형식의 실제 달력 날짜여야 합니다'); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); @@ -733,7 +733,7 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-task"]').fill(''); const saveButton = page.locator('.editor-panel').getByRole('button', { name: '저장' }); - await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toBeDisabled(); await expect(page.locator('[data-testid="editor-task"]')).toHaveAttribute('aria-invalid', 'true'); await expect(page.locator('#editor-errors')).toContainText('HTML 태그 문자를 사용할 수 없습니다'); await expect(page.locator('.editor-panel')).toBeVisible(); @@ -799,19 +799,6 @@ test.describe('ScopeWeave Planner', () => { await expectSaveBlockedWith(page, '최상위 작업은 단계 값을 입력해야 합니다.'); }); - test('shows the invalid-save toast when clicking Save with an empty required field', async ({ page }) => { - await page.getByRole('button', { name: '최상위 작업 추가' }).click(); - await page.locator('[data-testid="editor-phase"]').fill(''); - // The native `required` attribute must not silently block the submit event - // before validateDraft/saveEditor run (form.noValidate = true). The button - // is aria-disabled (not natively disabled) so it stays real-world clickable; - // force the click since Playwright's actionability check treats - // aria-disabled as non-actionable. - await page.getByRole('button', { name: '저장', exact: true }).click({ force: true }); - await expect(page.locator('#toast')).toContainText('입력값을 올바르게 수정해야 저장할 수 있습니다.'); - await expect(page.locator('.editor-panel')).toBeVisible(); - }); - test('rejects saving a depth 2 task without an activity', async ({ page }) => { await page.locator('tbody tr[data-task-id]').first().getByRole('button', { name: '하위 추가' }).click(); await page.locator('[data-testid="editor-activity"]').fill(''); From 01a95f1442eff4898069df4dd6fdb84366ee5351 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:35:44 +0000 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 버튼의 기본 disabled 속성을 확실히 해제하고 aria-disabled로 교체하여 탭 포커스 유지 - 유효성 검사 실패 시 토스트 메시지를 통해 명확한 에러 피드백 제공 - CSS의 :not(:disabled):not([aria-disabled="true"])와 연동되어 시각적 비활성화 상태 유지 - 스크린 리더 사용자가 비활성화된 이유를 명확히 인지할 수 있도록 개선 From a5891c9b4b6796b4c36024c8812d32b77223be43 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:58:20 +0000 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 버튼의 기본 disabled 속성을 확실히 해제하고 aria-disabled로 교체하여 탭 포커스 유지 - 유효성 검사 실패 시 토스트 메시지를 통해 명확한 에러 피드백 제공 - CSS의 :not(:disabled):not([aria-disabled="true"])와 연동되어 시각적 비활성화 상태 유지 - 스크린 리더 사용자가 비활성화된 이유를 명확히 인지할 수 있도록 개선