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:
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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user