Milestone v1.0: FamilySync MVP #1
@@ -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<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
// IN-05: shared with SeriesEditPrompt via useFocusTrap so the trap logic lives once.
|
||||
const handleDialogKeyDown = useFocusTrap(dialogRef)
|
||||
|
||||
// ── Focus: move to title input on open ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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<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) {
|
||||
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
|
||||
|
||||
|
||||
@@ -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