From 1ab9710066f800dca3b3ad9dd8fd13f40a5a8969 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 16:55:30 -0400 Subject: [PATCH] fix(06): IN-05 extract duplicated dialog focus-trap into shared useFocusTrap hook --- apps/pwa/src/components/EventForm.tsx | 37 ++------------- apps/pwa/src/components/SeriesEditPrompt.tsx | 30 ++---------- apps/pwa/src/hooks/useFocusTrap.ts | 50 ++++++++++++++++++++ 3 files changed, 56 insertions(+), 61 deletions(-) create mode 100644 apps/pwa/src/hooks/useFocusTrap.ts diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index 5f10136..34a5053 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -46,6 +46,7 @@ import { computeNewAllDayEnd, } from '../lib/eventDateTime.js' import { SeriesEditPrompt } from './SeriesEditPrompt.js' +import { useFocusTrap } from '../hooks/useFocusTrap.js' // ── Constants ───────────────────────────────────────────────────────────────── @@ -460,40 +461,8 @@ export function EventForm() { }, [eventFormOpen]) // eslint-disable-line react-hooks/exhaustive-deps // ── Focus trap: Tab / Shift+Tab cycles within dialog (WR-07) ─────────────── - // - // Queries all standard focusable elements inside the dialog on each keydown. - // If Tab is pressed on the last focusable element, wraps to the first. - // If Shift+Tab is pressed on the first focusable element, wraps to the last. - // No external library — implemented inline to avoid adding a dependency. - - const handleDialogKeyDown = (e: React.KeyboardEvent) => { - if (e.key !== 'Tab' || !dialogRef.current) return - - const focusable = Array.from( - dialogRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ), - ).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1') - - if (focusable.length === 0) return - - const first = focusable[0] - const last = focusable[focusable.length - 1] - - if (e.shiftKey) { - // Shift+Tab: if on first element, wrap to last - if (document.activeElement === first) { - e.preventDefault() - last.focus() - } - } else { - // Tab: if on last element, wrap to first - if (document.activeElement === last) { - e.preventDefault() - first.focus() - } - } - } + // IN-05: shared with SeriesEditPrompt via useFocusTrap so the trap logic lives once. + const handleDialogKeyDown = useFocusTrap(dialogRef) // ── Focus: move to title input on open ───────────────────────────────────── diff --git a/apps/pwa/src/components/SeriesEditPrompt.tsx b/apps/pwa/src/components/SeriesEditPrompt.tsx index e1a586e..b5a87fc 100644 --- a/apps/pwa/src/components/SeriesEditPrompt.tsx +++ b/apps/pwa/src/components/SeriesEditPrompt.tsx @@ -23,6 +23,7 @@ */ import { useEffect, useRef } from 'react' +import { useFocusTrap } from '../hooks/useFocusTrap.js' // ── Component ───────────────────────────────────────────────────────────────── @@ -55,33 +56,8 @@ export function SeriesEditPrompt({ open, onConfirm, onCancel }: SeriesEditPrompt return () => document.removeEventListener('keydown', onKeyDown) }, [open, onCancel]) - // Focus trap: Tab / Shift+Tab cycles within dialog - const handleDialogKeyDown = (e: React.KeyboardEvent) => { - if (e.key !== 'Tab' || !dialogRef.current) return - - const focusable = Array.from( - dialogRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ), - ).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1') - - if (focusable.length === 0) return - - const first = focusable[0] - const last = focusable[focusable.length - 1] - - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault() - last.focus() - } - } else { - if (document.activeElement === last) { - e.preventDefault() - first.focus() - } - } - } + // Focus trap: Tab / Shift+Tab cycles within dialog (IN-05: shared via useFocusTrap) + const handleDialogKeyDown = useFocusTrap(dialogRef) if (!open) return null diff --git a/apps/pwa/src/hooks/useFocusTrap.ts b/apps/pwa/src/hooks/useFocusTrap.ts new file mode 100644 index 0000000..75cfafa --- /dev/null +++ b/apps/pwa/src/hooks/useFocusTrap.ts @@ -0,0 +1,50 @@ +/** + * useFocusTrap — Tab / Shift+Tab focus cycling within a dialog (IN-05). + * + * Extracted from the verbatim-duplicated handleDialogKeyDown in EventForm.tsx and + * SeriesEditPrompt.tsx so a future fix (handling disabled/hidden elements, radio-group + * focus, etc.) lands in one place. + * + * Returns a keydown handler to spread onto the dialog container's `onKeyDown`. The + * handler queries the dialog's focusable descendants on each keydown: + * - Tab on the last focusable element wraps to the first. + * - Shift+Tab on the first focusable element wraps to the last. + * + * No external library — keeps the zero-dependency posture of the original inline impl. + * + * @param dialogRef - ref to the dialog container element + */ +import type { KeyboardEvent, RefObject } from 'react' + +export function useFocusTrap( + dialogRef: RefObject, +): (e: KeyboardEvent) => void { + return (e: KeyboardEvent) => { + if (e.key !== 'Tab' || !dialogRef.current) return + + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1') + + if (focusable.length === 0) return + + const first = focusable[0] + const last = focusable[focusable.length - 1] + + if (e.shiftKey) { + // Shift+Tab: if on first element, wrap to last + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + // Tab: if on last element, wrap to first + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } +}