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,
|
forwardRef, useCallback, useId, useLayoutEffect, useRef, useState,
|
||||||
type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode,
|
type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
|
import { moveRovingFocus } from "../shared/roving-focus"
|
||||||
import type { SWFooterAuxItem } from "./types"
|
import type { SWFooterAuxItem } from "./types"
|
||||||
import "../../theme/sw-footer.css"
|
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). */
|
/** Arrow / Home / End focus move between the enabled buttons matching `selector` (roving focus). */
|
||||||
export function moveFocus(event: KeyboardEvent<HTMLElement>, selector: string, select = false) {
|
export function moveFocus(event: KeyboardEvent<HTMLElement>, selector: string, select = false) {
|
||||||
const keys = ["ArrowRight", "ArrowLeft", "Home", "End"]
|
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>(selector)).filter(item => !item.disabled), { select })
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Side button (보조기능 전체보기 / 명령어 전체보기) ----------
|
// ---------- Side button (보조기능 전체보기 / 명령어 전체보기) ----------
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
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 documentIcon from "../../../assets/document-icon.svg"
|
||||||
import type { SWHeaderDocument } from "../types"
|
import type { SWHeaderDocument } from "../types"
|
||||||
|
|
||||||
@@ -85,14 +86,7 @@ export function SWHeaderDocumentTitle({
|
|||||||
if (restoreFocus) triggerRef.current?.focus()
|
if (restoreFocus) triggerRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useOutsidePointerDown(open, rootRef, () => setOpen(false))
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && focusFirstRef.current) {
|
if (open && focusFirstRef.current) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent, type RefObject } from "react"
|
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 detailIcon from "../../../assets/layer-detail.svg"
|
||||||
import type { ButtonHTMLAttributes, ReactNode } from "react"
|
import type { ButtonHTMLAttributes, ReactNode } from "react"
|
||||||
|
|
||||||
@@ -77,14 +78,7 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
|
|||||||
if (restoreFocus) triggerRef.current?.focus()
|
if (restoreFocus) triggerRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useOutsidePointerDown(open, rootRef, () => setOpen(false))
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"
|
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"
|
||||||
|
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
|
||||||
import { SWHeaderMenuDetail, useSWFixedDropdownPosition } from "./SWHeaderDropdown"
|
import { SWHeaderMenuDetail, useSWFixedDropdownPosition } from "./SWHeaderDropdown"
|
||||||
import type { SWHeaderLayer, SWHeaderLayerValue } from "../types"
|
import type { SWHeaderLayer, SWHeaderLayerValue } from "../types"
|
||||||
import { SWHeaderLayerControl } from "./SWHeaderFields"
|
import { SWHeaderLayerControl } from "./SWHeaderFields"
|
||||||
@@ -78,14 +79,7 @@ export function SWHeaderLayerMenu({
|
|||||||
if (restoreFocus) triggerRef.current?.focus()
|
if (restoreFocus) triggerRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useOutsidePointerDown(open, rootRef, () => setOpen(false))
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
|
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
|
||||||
|
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
|
||||||
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
|
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
|
||||||
|
|
||||||
export interface SWHeaderMoreItem {
|
export interface SWHeaderMoreItem {
|
||||||
@@ -40,14 +41,7 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
|
|||||||
const [guideOpen, setGuideOpen] = useState(false)
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
|
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useOutsidePointerDown(open, rootRef, () => setOpen(false))
|
||||||
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])
|
|
||||||
|
|
||||||
const visibleItems = () => Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null)
|
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,
|
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
|
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
|
||||||
|
import { moveRovingFocus } from "../shared/roving-focus"
|
||||||
import titleBadge from "./assets/title-badge.svg"
|
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"].
|
// Imported where the component is used (not only in index.ts) so bundlers keep it with sideEffects: ["**/*.css"].
|
||||||
import "../../theme/sw-home-menu.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()
|
else rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))]?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
|
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) moveRovingFocus(event, focusableItems(rootRef.current))
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
|
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 { 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):
|
* 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>) {
|
function moveFocus(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"]
|
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)')), { vertical: true, select: true })
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SWToolGroup = forwardRef<HTMLDivElement, SWToolGroupProps>(function SWToolGroup(
|
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 { 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 { SWViewerMenu } from "./SWViewerMenu"
|
||||||
import { SWViewerSwitch } from "./SWViewerControls"
|
import { SWViewerSwitch } from "./SWViewerControls"
|
||||||
import { SWViewerIcon } from "./SWViewerIcon"
|
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 }) {
|
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 select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) }
|
||||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"]
|
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)')), { vertical: true, select: true })
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value
|
const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in new issue
Block a user