fix(06): IN-05 extract duplicated dialog focus-trap into shared useFocusTrap hook

This commit is contained in:
Lucas Berger
2026-06-10 16:55:30 -04:00
parent a570135a8d
commit 1ab9710066
3 changed files with 56 additions and 61 deletions
+3 -34
View File
@@ -46,6 +46,7 @@ import {
computeNewAllDayEnd, computeNewAllDayEnd,
} from '../lib/eventDateTime.js' } from '../lib/eventDateTime.js'
import { SeriesEditPrompt } from './SeriesEditPrompt.js' import { SeriesEditPrompt } from './SeriesEditPrompt.js'
import { useFocusTrap } from '../hooks/useFocusTrap.js'
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
@@ -460,40 +461,8 @@ export function EventForm() {
}, [eventFormOpen]) // eslint-disable-line react-hooks/exhaustive-deps }, [eventFormOpen]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Focus trap: Tab / Shift+Tab cycles within dialog (WR-07) ─────────────── // ── Focus trap: Tab / Shift+Tab cycles within dialog (WR-07) ───────────────
// // IN-05: shared with SeriesEditPrompt via useFocusTrap so the trap logic lives once.
// Queries all standard focusable elements inside the dialog on each keydown. const handleDialogKeyDown = useFocusTrap(dialogRef)
// 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<HTMLDivElement>) => {
if (e.key !== 'Tab' || !dialogRef.current) return
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'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()
}
}
}
// ── Focus: move to title input on open ───────────────────────────────────── // ── Focus: move to title input on open ─────────────────────────────────────
+3 -27
View File
@@ -23,6 +23,7 @@
*/ */
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useFocusTrap } from '../hooks/useFocusTrap.js'
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
@@ -55,33 +56,8 @@ export function SeriesEditPrompt({ open, onConfirm, onCancel }: SeriesEditPrompt
return () => document.removeEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown)
}, [open, onCancel]) }, [open, onCancel])
// Focus trap: Tab / Shift+Tab cycles within dialog // Focus trap: Tab / Shift+Tab cycles within dialog (IN-05: shared via useFocusTrap)
const handleDialogKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const handleDialogKeyDown = useFocusTrap(dialogRef)
if (e.key !== 'Tab' || !dialogRef.current) return
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'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()
}
}
}
if (!open) return null if (!open) return null
+50
View File
@@ -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<HTMLDivElement | null>,
): (e: KeyboardEvent<HTMLDivElement>) => void {
return (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Tab' || !dialogRef.current) return
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'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()
}
}
}
}