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])
}
@@ -2,6 +2,7 @@ import {
forwardRef, useCallback, useId, useLayoutEffect, useRef, useState,
type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode,
} from "react"
import { moveRovingFocus } from "../shared/roving-focus"
import type { SWFooterAuxItem } from "./types"
import "../../theme/sw-footer.css"
@@ -9,16 +10,7 @@ const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).j
/** Arrow / Home / End focus move between the enabled buttons matching `selector` (roving focus). */
export function moveFocus(event: KeyboardEvent<HTMLElement>, selector: string, select = false) {
const keys = ["ArrowRight", "ArrowLeft", "Home", "End"]
if (!keys.includes(event.key)) return
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>(selector)).filter(item => !item.disabled)
const index = items.indexOf(document.activeElement as HTMLButtonElement)
if (index < 0 || !items.length) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (index + (event.key === "ArrowRight" ? 1 : -1) + items.length) % items.length
items[next].focus()
if (select) items[next].click()
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>(selector)).filter(item => !item.disabled), { select })
}
// ---------- Side button (보조기능 전체보기 / 명령어 전체보기) ----------
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
import documentIcon from "../../../assets/document-icon.svg"
import type { SWHeaderDocument } from "../types"
@@ -85,14 +86,7 @@ export function SWHeaderDocumentTitle({
if (restoreFocus) triggerRef.current?.focus()
}
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
})
useOutsidePointerDown(open, rootRef, () => setOpen(false))
useEffect(() => {
if (open && focusFirstRef.current) {
@@ -1,4 +1,5 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent, type RefObject } from "react"
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
import detailIcon from "../../../assets/layer-detail.svg"
import type { ButtonHTMLAttributes, ReactNode } from "react"
@@ -77,14 +78,7 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
if (restoreFocus) triggerRef.current?.focus()
}
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
})
useOutsidePointerDown(open, rootRef, () => setOpen(false))
useLayoutEffect(() => {
if (!open) return
@@ -1,4 +1,5 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
import { SWHeaderMenuDetail, useSWFixedDropdownPosition } from "./SWHeaderDropdown"
import type { SWHeaderLayer, SWHeaderLayerValue } from "../types"
import { SWHeaderLayerControl } from "./SWHeaderFields"
@@ -78,14 +79,7 @@ export function SWHeaderLayerMenu({
if (restoreFocus) triggerRef.current?.focus()
}
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
})
useOutsidePointerDown(open, rootRef, () => setOpen(false))
useLayoutEffect(() => {
if (!open) return
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
export interface SWHeaderMoreItem {
@@ -40,14 +41,7 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
const [guideOpen, setGuideOpen] = useState(false)
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [open])
useOutsidePointerDown(open, rootRef, () => setOpen(false))
const visibleItems = () => Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null)
@@ -3,6 +3,7 @@ import {
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
} from "react"
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
import { moveRovingFocus } from "../shared/roving-focus"
import titleBadge from "./assets/title-badge.svg"
// Imported where the component is used (not only in index.ts) so bundlers keep it with sideEffects: ["**/*.css"].
import "../../theme/sw-home-menu.css"
@@ -354,14 +355,7 @@ export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function S
else rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))]?.focus()
return
}
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
const items = focusableItems(rootRef.current)
const index = items.indexOf(document.activeElement as HTMLElement)
if (index < 0) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (index + (event.key === "ArrowRight" ? 1 : -1) + items.length) % items.length
items[next].focus()
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) moveRovingFocus(event, focusableItems(rootRef.current))
}
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
+2 -10
View File
@@ -1,4 +1,5 @@
import { createContext, forwardRef, useContext, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { moveRovingFocus } from "../shared/roving-focus"
/**
* Existing SW button looks, reused as one public button (no new design values):
@@ -77,16 +78,7 @@ export interface SWToolGroupProps extends HTMLAttributes<HTMLDivElement> {
}
function moveFocus(event: KeyboardEvent<HTMLDivElement>) {
const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"]
if (!keys.includes(event.key)) return
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLButtonElement)
if (index < 0 || !items.length) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
items[next].focus()
items[next].click()
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)')), { vertical: true, select: true })
}
export const SWToolGroup = forwardRef<HTMLDivElement, SWToolGroupProps>(function SWToolGroup(
@@ -1,4 +1,5 @@
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { moveRovingFocus } from "../shared/roving-focus"
import { SWViewerMenu } from "./SWViewerMenu"
import { SWViewerSwitch } from "./SWViewerControls"
import { SWViewerIcon } from "./SWViewerIcon"
@@ -111,15 +112,7 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeade
function Tabs({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "tabs" }>; onCommand: (c: SWViewerCommand) => void }) {
const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) }
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"]
if (!keys.includes(event.key)) return
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)'))
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (index < 0) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length
buttons[next].focus()
buttons[next].click()
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)')), { vertical: true, select: true })
}
const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value
return (