refactor(sw): share roving focus and outside pointer handling

- components/shared/roving-focus.ts (moveRovingFocus): the same Arrow / Home / End algorithm of SWFooter, SWToolGroup,
  the SWViewerHeader tabs and SWHomeMenu; each caller passes its own keys, wrap and select behavior.
- components/shared/use-outside-pointer.ts (useOutsidePointerDown): the same pointerdown effect of the SWHeader
  dropdowns, layer menu, document title and More. Escape, focus return and positioning stay in each component.
Both modules are internal: the package entry and the public declarations are unchanged.
This commit is contained in:
shkim4 committed 2026-10-21 06:08:45 +09:00
1 parent af7f98d66b
commit 7cc3aa1ff8
10 files changed
+55 -69

No files matched your search

@@ -0,0 +1,20 @@
import type { KeyboardEvent } from "react"
/**
* Internal (not exported from the package): roving focus among `items`, shared by SWFooter, SWToolGroup, the
* SWViewerHeader tabs and SWHomeMenu (2026-10-20; the four copies had the same algorithm). ArrowRight / ArrowLeft —
* and ArrowDown / ArrowUp when `vertical` — move and wrap; Home / End jump. Nothing happens unless the focused element
* is one of `items`. `select` clicks the new item (radio groups). Returns whether the key was handled.
*/
export function moveRovingFocus(event: KeyboardEvent<HTMLElement>, items: HTMLElement[], { vertical = false, select = false } = {}) {
const forward = event.key === "ArrowRight" || (vertical && event.key === "ArrowDown")
const backward = event.key === "ArrowLeft" || (vertical && event.key === "ArrowUp")
if (!forward && !backward && event.key !== "Home" && event.key !== "End") return false
const index = items.indexOf(document.activeElement as HTMLElement)
if (index < 0) return false
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (forward ? 1 : -1) + items.length) % items.length
items[next].focus()
if (select) items[next].click()
return true
}
@@ -0,0 +1,19 @@
import { useEffect, useRef, type RefObject } from "react"
/**
* Internal (not exported from the package): while `open`, a pointerdown outside `rootRef` calls `onOutside` (the latest
* one). Shared by the SWHeader dropdowns, layer menu, document title and More (2026-10-20; the four copies were the same
* effect). Escape, focus return and positioning stay in each component.
*/
export function useOutsidePointerDown(open: boolean, rootRef: RefObject<HTMLElement | null>, onOutside: () => void) {
const latest = useRef(onOutside)
latest.current = onOutside
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) latest.current()
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [open, rootRef])
}