diff --git a/.Jules/palette.md b/.Jules/palette.md index 7577a1ca7..ec2791de5 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -56,3 +56,10 @@ ## 2024-07-14 - Native Keyboard Submission with Forms for Modals **Learning:** Modals designed with plain `
` elements as wrappers instead of `
` lack native keyboard submission support, forcing users to switch from keyboard to mouse to confirm actions like "Save". **Action:** When designing modals or popups containing inputs, always use a `` element to wrap the content, handle the `onSubmit` event (calling `e.preventDefault()`), and set the primary confirmation button to `type="submit"` to enable seamless Enter-key submission for keyboard users. +## 2026-06-30 - Dynamically Disabled Buttons with Explanatory Hints +**Learning:** Using the native `disabled` attribute on buttons removes them from the tab sequence. This makes it impossible for keyboard or screen reader users to reach the button and discover any attached explanatory hints (like `aria-describedby` explaining why it's disabled). +**Action:** When a dynamically disabled button provides important contextual hints about its disabled state, use `aria-disabled="true"` instead of the native `disabled` attribute. Ensure visual disabled styling (opacity, cursor) is applied and an `onClick={e => e.preventDefault()}` handler is added to prevent submission while maintaining discoverability in the tab sequence. + +## 2026-06-30 - Prevent Default Event Bubbling on Disabled Buttons +**Learning:** Using `onClick={e => e.preventDefault()}` on an `aria-disabled` button does not prevent the click event from bubbling up the DOM tree in React. Tests mimicking clicks on the disabled button might still trigger parent handlers, causing assertions like `expect(parentHandler).not.toHaveBeenCalled()` to fail. +**Action:** When creating an `aria-disabled` button and attempting to block activation, use `onClick={e => { e.preventDefault(); e.stopPropagation(); }}` to ensure the event does not bubble up to parent containers. diff --git a/frontend/src/components/modals/ExportModal.test.tsx b/frontend/src/components/modals/ExportModal.test.tsx index bd89c5384..8464e0c2f 100644 --- a/frontend/src/components/modals/ExportModal.test.tsx +++ b/frontend/src/components/modals/ExportModal.test.tsx @@ -171,13 +171,27 @@ describe('ExportModal', () => { expect(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })).toBeDisabled(); }); - it('exposes access-control guidance for disabled button', () => { - render(); + it('keeps access-control guidance focusable while fully blocking activation', async () => { + const parentClick = vi.fn(); + render( +
+ +
+ ); expect(screen.getByText('접근 권한 관리는 프로젝트 권한 설정에서 처리합니다.')).toBeInTheDocument(); const accessManagementButton = screen.getByRole('button', { name: '접근 관리' }); - expect(accessManagementButton).toBeDisabled(); + + // Verify it is functionally disabled but discoverable + expect(accessManagementButton).toHaveAttribute('aria-disabled', 'true'); + accessManagementButton.focus(); + expect(accessManagementButton).toHaveFocus(); + expect(accessManagementButton).toHaveAttribute('aria-describedby', 'share-export-access-hint'); expect(accessManagementButton).not.toHaveAttribute('title'); + + // Ensure it does not trigger click events or bubble up + fireEvent.click(accessManagementButton); + expect(parentClick).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/modals/ExportModal.tsx b/frontend/src/components/modals/ExportModal.tsx index 995393ceb..e778358bf 100644 --- a/frontend/src/components/modals/ExportModal.tsx +++ b/frontend/src/components/modals/ExportModal.tsx @@ -202,7 +202,8 @@ export function ExportModal({ )}