diff --git a/.jules/palette.md b/.jules/palette.md index bd0f73248..9bb6397cf 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -57,3 +57,6 @@ ## 2026-07-30 - Add window.confirm for destructive actions **Learning:** Destructive actions like deleting groups and edge relationships previously occurred immediately without user confirmation. **Action:** Always wrap delete operations with window.confirm() dialogs and ensure corresponding tests successfully mock window.confirm. +## 2024-05-24 - Fix Keyboard Accessibility on Forms +**Learning:** Users cannot use the 'Enter' key to submit forms if inputs and action buttons are wrapped in generic `
` tags instead of native `
` elements with an `onSubmit` handler. +**Action:** Ensure inputs and their corresponding action buttons are wrapped in native `` tags with an `onSubmit={(e) => e.preventDefault(); ...}` handler and `type="submit"` buttons to provide native keyboard accessibility. diff --git a/frontend/src/App.nativeFormSubmission.test.tsx b/frontend/src/App.nativeFormSubmission.test.tsx new file mode 100644 index 000000000..f233eca73 --- /dev/null +++ b/frontend/src/App.nativeFormSubmission.test.tsx @@ -0,0 +1,110 @@ +import '@testing-library/jest-dom/vitest' +import userEvent from '@testing-library/user-event' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ + getMe: vi.fn(), + listProjects: vi.fn(), + listConnections: vi.fn(), + listSnapshots: vi.fn(), + createProject: vi.fn(), + createConnection: vi.fn(), + createSnapshot: vi.fn(), + getSnapshot: vi.fn(), + createShareLink: vi.fn(), +})) + +vi.mock('./api', () => api) + +globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +import App from './App' + +beforeEach(() => { + vi.clearAllMocks() + api.getMe.mockResolvedValue({ subject: 'test-user', display_name: 'Test User' }) + api.listProjects.mockResolvedValue([ + { project_space_uuid: 'project-1', project_name: 'Billing' }, + ]) + api.listConnections.mockResolvedValue([ + { db_connection_uuid: 'connection-1', conn_name: 'Warehouse' }, + ]) + api.listSnapshots.mockResolvedValue([]) + api.createProject.mockResolvedValue({ + project_space_uuid: 'project-created', + project_name: 'Keyboard project', + }) + api.createConnection.mockResolvedValue({ + db_connection_uuid: 'connection-created', + conn_name: 'Keyboard DB', + }) + api.createSnapshot.mockResolvedValue({ + schema_snapshot_uuid: 'snapshot-created', + status: 'queued', + schema_filter: 'audit', + }) + api.getSnapshot.mockResolvedValue({ + schema_snapshot_uuid: 'snapshot-created', + status: 'succeeded', + schema_filter: 'audit', + error_message: null, + snapshot_json: { relations: [], columns: [], pk_columns: [], fk_edges: [] }, + }) + api.createShareLink.mockResolvedValue({ url: 'http://localhost/share/example' }) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('native form keyboard submission', () => { + it('submits the sidebar create actions with Enter without duplicate activation', async () => { + const user = userEvent.setup() + render() + await screen.findByRole('heading', { name: '대시보드' }) + + await user.click(screen.getByRole('button', { name: '편집기' })) + + const projectName = await screen.findByLabelText('New project') + await user.clear(projectName) + await user.type(projectName, 'Keyboard project{Enter}') + await waitFor(() => expect(api.createProject).toHaveBeenCalledTimes(1)) + expect(api.createProject).toHaveBeenCalledWith('Keyboard project') + + await waitFor(() => { + expect(api.listConnections).toHaveBeenCalledWith('project-created') + }) + + const connectionName = screen.getByLabelText('New connection (DSN)') + await user.clear(connectionName) + await user.type(connectionName, 'Keyboard DB') + const dsn = screen.getByLabelText('Connection DSN') + await user.clear(dsn) + await user.type(dsn, 'postgresql://db.example.test/app{Enter}') + await waitFor(() => expect(api.createConnection).toHaveBeenCalledTimes(1)) + + const schemaFilter = screen.getByLabelText('Schema filter (optional)') + await user.type(schemaFilter, 'audit{Enter}') + await waitFor(() => expect(api.createSnapshot).toHaveBeenCalledTimes(1)) + }) + + it('submits the projects-page inline create form with Enter', async () => { + const user = userEvent.setup() + render() + await screen.findByRole('heading', { name: '대시보드' }) + + await user.click(screen.getByRole('button', { name: '프로젝트' })) + const projectName = await screen.findByLabelText('새 프로젝트 이름') + await user.clear(projectName) + await user.type(projectName, 'Keyboard project{Enter}') + + await waitFor(() => expect(api.createProject).toHaveBeenCalledTimes(1)) + expect(api.createProject).toHaveBeenCalledWith('Keyboard project') + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49812e448..93dc31ed9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1114,15 +1114,14 @@ export default function App() {
-
+ { e.preventDefault(); onCreateProject(); }}> setProjectName(e.target.value)} /> -
+ {createProjectHint ? ( {createProjectHint} @@ -1160,7 +1159,7 @@ export default function App() {
-
+
{ e.preventDefault(); onCreateConnection(); }}>
+ -
+
{ e.preventDefault(); onCreateSnapshot(); }}> setSchemaFilter(e.target.value)} placeholder="public" /> -
- - + + {createSnapshotHint ? ( {createSnapshotHint} @@ -1343,20 +1340,19 @@ export default function App() {

프로젝트

프로젝트를 선택하면 해당 다이어그램 목록을 볼 수 있습니다.

-
+
{ e.preventDefault(); onCreateProject(); }}> setProjectName(event.currentTarget.value)} /> -
+