Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
3 changed files with 56 additions and 61 deletions
Showing only changes of commit 1ab9710066 - Show all commits
+3 -34
View File
@@ -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 ─────────────────────────────────────
+3 -27
View File
@@ -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
+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()
}
}
}
}