feat(03-12): GREEN — WR-07 real focus trap on EventForm dialog
Add Tab/Shift+Tab focus trap to the dialog element: - onKeyDown handler queries all focusable elements inside dialogRef - Tab from last element wraps to first (preventDefault) - Shift+Tab from first element wraps to last (preventDefault) - No new dependency — implemented inline with dialogRef - Existing focus-on-open (titleRef) and Escape-to-close unchanged - Update docblock: focus trap claim is now accurate (WR-07)
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
* Accessibility:
|
||||
* - role="dialog", aria-modal="true", aria-label="New Event"/"Edit Event"
|
||||
* - Focus moves to Title input on open
|
||||
* - Focus trap: Tab/Shift+Tab cycle focus within dialog; never reaches background (WR-07)
|
||||
* - Escape / backdrop click closes form
|
||||
* - All-day toggle: role="switch", aria-checked
|
||||
* - Recurrence: <select> with labeled options
|
||||
@@ -111,6 +112,7 @@ export function EventForm() {
|
||||
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid)
|
||||
const queryClient = useQueryClient()
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// ── Resolve event for edit mode ─────────────────────────────────────────────
|
||||
|
||||
@@ -285,6 +287,42 @@ export function EventForm() {
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Focus: move to title input on open ─────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
@@ -392,10 +430,12 @@ export function EventForm() {
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
style={dialogStyle}
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
Reference in New Issue
Block a user