diff --git a/package.json b/package.json index 5d50e6a..9243186 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "playwright test", "test:ui": "playwright test --ui", "test:unit": "vitest run", + "test:a11y": "playwright test tests/a11y/", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test-storybook": "test-storybook" diff --git a/src/components/QRCodeModal.stories.tsx b/src/components/QRCodeModal.stories.tsx new file mode 100644 index 0000000..af5be9d --- /dev/null +++ b/src/components/QRCodeModal.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn, within, userEvent, expect } from '@storybook/test'; +import { QRCodeModal } from './QRCodeModal'; +import { SAMPLE_META_ADDRESS } from '../../.storybook/fixtures'; + +const meta = { + title: 'A11y/QRCodeModal', + component: QRCodeModal, + parameters: { layout: 'fullscreen' }, + args: { + value: SAMPLE_META_ADDRESS, + onClose: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Modal open with a single QR variant — the default state tested for a11y. */ +export const Open: Story = {}; + +/** Modal with two variants (meta-address + Stellar URI toggle). */ +export const WithVariants: Story = { + args: { + title: 'Stealth Meta-Address', + variants: [ + { label: 'Meta-address', value: SAMPLE_META_ADDRESS }, + { label: 'Stellar URI', value: `web+stellar:pay?destination=${SAMPLE_META_ADDRESS}` }, + ], + }, +}; + +/** Escape key should call onClose. */ +export const EscapeCloses: Story = { + play: async ({ canvasElement, args }) => { + // The modal is rendered — press Escape and verify onClose was called. + canvasElement.ownerDocument.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await expect(args.onClose).toHaveBeenCalled(); + }, +}; + +/** Close button should call onClose. */ +export const CloseButton: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: /close modal/i })); + await expect(args.onClose).toHaveBeenCalled(); + }, +}; diff --git a/src/components/QRCodeModal.tsx b/src/components/QRCodeModal.tsx index a4fd934..a1047d8 100644 --- a/src/components/QRCodeModal.tsx +++ b/src/components/QRCodeModal.tsx @@ -1,12 +1,15 @@ import { useEffect, useRef, useState } from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { CopyButton } from '@/components/CopyButton'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; interface QRCodeModalProps { value: string; onClose: () => void; title?: string; variants?: Array<{ label: string; value: string }>; + /** Ref to the element that triggered this modal — focus returns here on close. */ + triggerRef?: React.RefObject; } export function QRCodeModal({ @@ -14,24 +17,29 @@ export function QRCodeModal({ onClose, title = 'Stealth Meta-Address', variants, + triggerRef, }: QRCodeModalProps) { const closeButtonRef = useRef(null); + const dialogRef = useRef(null); const [selectedVariant, setSelectedVariant] = useState(0); const qrVariants = variants?.length ? variants : [{ label: 'Meta-address', value }]; const activeVariant = qrVariants[Math.min(selectedVariant, qrVariants.length - 1)]; - // Close on Escape key press, and focus close button on mount - useEffect(() => { - if (closeButtonRef.current) { - closeButtonRef.current.focus(); - } + // Focus trap — keeps Tab/Shift+Tab inside the modal; returns focus on close. + useFocusTrap({ + isActive: true, + containerRef: dialogRef, + initialFocusRef: closeButtonRef, + triggerRef, + }); + // Escape key closes the modal (existing behaviour, preserved). + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }; - window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); @@ -65,7 +73,10 @@ export function QRCodeModal({ aria-modal="true" aria-labelledby="qr-modal-title" > -
+