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
+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()
}
}
}
}