Code-review follow-ups: httputil, validation caps, a11y
Backend: - Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/ ServerError); drop the triple-duplicated helpers in docs, suggestions, vocab. ServerError now logs the real error and returns a generic 500 so raw DB/internal errors never reach the client. - vocab capture: validate doc_id ownership (blank -> none, unknown -> 400 instead of a leaked FK 500); rune-safe clamp word/gloss/definition/ phonetic/example. - vocab review(): wrap the read-modify-write in a transaction (TOCTOU). - /api request-size cap via MaxBytesReader middleware (2 MiB), exempting /api/images (own 10 MiB limit). Frontend: - StatusBar: drive the checking/voicing/collocating indicators from one array; llmDown uses !anyBusy. - Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore) on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label. - speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount. Tests: add doc_id-validation and field-clamp coverage; full suite green. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// useFocusTrap makes a slide-over / modal keyboard-accessible. Attach the
|
||||
// returned ref to the dialog container and, while it's mounted, it:
|
||||
// • moves focus into the panel on open (so Tab/Escape work without a click),
|
||||
// • keeps Tab/Shift+Tab cycling within the panel instead of escaping to the
|
||||
// page behind the backdrop, and
|
||||
// • restores focus to whatever was focused before it opened on unmount.
|
||||
// Escape handling stays with each panel, which has its own close semantics.
|
||||
export function useFocusTrap<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current
|
||||
if (!node) return
|
||||
|
||||
// Remember where focus was so we can hand it back when the panel closes.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null
|
||||
|
||||
const focusables = () =>
|
||||
Array.from(
|
||||
node.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((el) => el.offsetParent !== null)
|
||||
|
||||
// Focus the first interactive element, or the container itself as a fallback.
|
||||
const first = focusables()[0]
|
||||
if (first) first.focus()
|
||||
else node.focus()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
const items = focusables()
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const firstEl = items[0]
|
||||
const lastEl = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
if (e.shiftKey && active === firstEl) {
|
||||
e.preventDefault()
|
||||
lastEl.focus()
|
||||
} else if (!e.shiftKey && active === lastEl) {
|
||||
e.preventDefault()
|
||||
firstEl.focus()
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
node.removeEventListener('keydown', onKeyDown)
|
||||
previouslyFocused?.focus?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return ref
|
||||
}
|
||||
Reference in New Issue
Block a user