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:
|
* Accessibility:
|
||||||
* - role="dialog", aria-modal="true", aria-label="New Event"/"Edit Event"
|
* - role="dialog", aria-modal="true", aria-label="New Event"/"Edit Event"
|
||||||
* - Focus moves to Title input on open
|
* - 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
|
* - Escape / backdrop click closes form
|
||||||
* - All-day toggle: role="switch", aria-checked
|
* - All-day toggle: role="switch", aria-checked
|
||||||
* - Recurrence: <select> with labeled options
|
* - Recurrence: <select> with labeled options
|
||||||
@@ -111,6 +112,7 @@ export function EventForm() {
|
|||||||
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid)
|
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const titleRef = useRef<HTMLInputElement>(null)
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// ── Resolve event for edit mode ─────────────────────────────────────────────
|
// ── Resolve event for edit mode ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -285,6 +287,42 @@ export function EventForm() {
|
|||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
}, [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) ───────────────
|
||||||
|
//
|
||||||
|
// 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 ─────────────────────────────────────
|
// ── Focus: move to title input on open ─────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -392,10 +430,12 @@ export function EventForm() {
|
|||||||
|
|
||||||
{/* Dialog */}
|
{/* Dialog */}
|
||||||
<div
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
onKeyDown={handleDialogKeyDown}
|
||||||
style={dialogStyle}
|
style={dialogStyle}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
Reference in New Issue
Block a user