diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 8e71fd15b..99b3ea413 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -1283,7 +1283,7 @@ export const TestIds = { credAddBtn: 'cred-add-btn', /** button · Clear filters */ credClearFilters: 'cred-clear-filters', - /** button */ + /** button · Close saved credentials */ credCloseBtn: 'cred-close-btn', /** button · Delete */ credDelete: (id: string | number) => `cred-delete-${id}`, diff --git a/apps/web/src/frontend/features/connections/components/CredentialManager.test.tsx b/apps/web/src/frontend/features/connections/components/CredentialManager.test.tsx new file mode 100644 index 000000000..c2a952b90 --- /dev/null +++ b/apps/web/src/frontend/features/connections/components/CredentialManager.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// The editor store loads the theme store, which reads the colour scheme. +window.matchMedia ??= ((media: string) => ({ + matches: false, + media, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +// The credential form is its own layer; a stand-in is enough to open it. +vi.mock('./ConnectionModal', () => ({ + ConnectionModal: ({ open }: { open: boolean }) => (open ?
{tab === 'database' ? 'Encrypted DB connections · reusable from the connection dropdowns' @@ -192,6 +199,8 @@ export const CredentialManager: React.FC = ({ open, onClose }) => { )} diff --git a/apps/web/src/frontend/features/sql-editor/components/WriteConfirmDialog.test.tsx b/apps/web/src/frontend/features/sql-editor/components/WriteConfirmDialog.test.tsx new file mode 100644 index 000000000..d4d77d7f7 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/WriteConfirmDialog.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { WriteConfirmDialog } from './WriteConfirmDialog'; + +describe('WriteConfirmDialog', () => { + it('is announced as a modal dialog named by its title', () => { + render( + undefined} + onConfirm={() => undefined} + /> + ); + const dialog = screen.getByRole('dialog', { name: 'Safe mode: run UPDATE statements?' }); + expect(dialog.getAttribute('aria-modal')).toBe('true'); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/WriteConfirmDialog.tsx b/apps/web/src/frontend/features/sql-editor/components/WriteConfirmDialog.tsx index 7a5469f2d..dfa002cb7 100644 --- a/apps/web/src/frontend/features/sql-editor/components/WriteConfirmDialog.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/WriteConfirmDialog.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useId, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { AlertCircle, Play, Info, ShieldAlert } from 'lucide-react'; import type { ReadonlyWriteTarget } from '@/features/sql-editor/state/useSqlEditorStore'; @@ -68,6 +68,7 @@ export const WriteConfirmDialog: React.FC = ({ return [...set]; }, [mutating]); + const titleId = useId(); const [ackedMissingWhere, setAckedMissingWhere] = useState(false); const needsWhereAck = requireMissingWhereAck && missingWhere.length > 0; const canConfirm = !needsWhereAck || ackedMissingWhere; @@ -88,12 +89,15 @@ export const WriteConfirmDialog: React.FC = ({ onClick={onCancel} > e.stopPropagation()} > - {title} + {title} {mutating.length > 0 && ( diff --git a/apps/web/src/frontend/shared/lib/useEscapeKey.ts b/apps/web/src/frontend/shared/lib/useEscapeKey.ts new file mode 100644 index 000000000..25e8a83db --- /dev/null +++ b/apps/web/src/frontend/shared/lib/useEscapeKey.ts @@ -0,0 +1,24 @@ +import { useEffect, useRef } from 'react'; + +/** + * Call `onEscape` when Escape is pressed, while `enabled`. A key another + * handler already claimed (`preventDefault`, for example an open menu inside + * the dialog) is left alone, and so is everything while `enabled` is false: + * a dialog with another layer open on top passes false, so Escape closes only + * the top one. + * + * Registered once per enabled state; callers may pass a fresh function on + * every render. + */ +export function useEscapeKey(onEscape: () => void, enabled = true): void { + const onEscapeRef = useRef(onEscape); + onEscapeRef.current = onEscape; + useEffect(() => { + if (!enabled) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !e.defaultPrevented) onEscapeRef.current(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [enabled]); +} diff --git a/docs/testing/TEST_IDS.md b/docs/testing/TEST_IDS.md index 410723c30..562ff32c3 100644 --- a/docs/testing/TEST_IDS.md +++ b/docs/testing/TEST_IDS.md @@ -671,7 +671,7 @@ Every control has a test ID: **1015** buttons, text boxes, selects and textareas - **CredentialManager** · `features/connections/components/CredentialManager.tsx` - `cred-add-btn` · button · Add Credential - `cred-clear-filters` · button · Clear filters - - `cred-close-btn` · button + - `cred-close-btn` · button · Close saved credentials - `cred-delete-{id}` · button · Delete - `cred-dialect-filter` · select · Filter by provider - `cred-edit-{id}` · button · Edit