fix(06): IN-05 extract duplicated dialog focus-trap into shared useFocusTrap hook
This commit is contained in:
@@ -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 ─────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user