From 7cc3aa1ff8ab0af0cc5b8d3d63837ee3c7c42a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= Date: Wed, 21 Oct 2026 06:08:45 +0900 Subject: [PATCH] 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. --- .../sw/src/components/shared/roving-focus.ts | 20 +++++++++++++++++++ .../components/shared/use-outside-pointer.ts | 19 ++++++++++++++++++ .../components/sw-footer/SWFooterParts.tsx | 12 ++--------- .../components/SWHeaderDocumentTitle.tsx | 10 ++-------- .../sw-header/components/SWHeaderDropdown.tsx | 10 ++-------- .../components/SWHeaderLayerMenu.tsx | 10 ++-------- .../sw-header/components/SWHeaderMore.tsx | 10 ++-------- .../components/sw-home-menu/SWHomeMenu.tsx | 10 ++-------- packages/sw/src/components/sw-tool/SWTool.tsx | 12 ++--------- .../components/sw-viewer/SWViewerHeader.tsx | 11 ++-------- 10 files changed, 55 insertions(+), 69 deletions(-) create mode 100644 packages/sw/src/components/shared/roving-focus.ts create mode 100644 packages/sw/src/components/shared/use-outside-pointer.ts diff --git a/packages/sw/src/components/shared/roving-focus.ts b/packages/sw/src/components/shared/roving-focus.ts new file mode 100644 index 0000000..0fad347 --- /dev/null +++ b/packages/sw/src/components/shared/roving-focus.ts @@ -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, 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 +} diff --git a/packages/sw/src/components/shared/use-outside-pointer.ts b/packages/sw/src/components/shared/use-outside-pointer.ts new file mode 100644 index 0000000..e22cf92 --- /dev/null +++ b/packages/sw/src/components/shared/use-outside-pointer.ts @@ -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, 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]) +} diff --git a/packages/sw/src/components/sw-footer/SWFooterParts.tsx b/packages/sw/src/components/sw-footer/SWFooterParts.tsx index 9d35b60..bd65222 100644 --- a/packages/sw/src/components/sw-footer/SWFooterParts.tsx +++ b/packages/sw/src/components/sw-footer/SWFooterParts.tsx @@ -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, selector: string, select = false) { - const keys = ["ArrowRight", "ArrowLeft", "Home", "End"] - if (!keys.includes(event.key)) return - const items = Array.from(event.currentTarget.querySelectorAll(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(selector)).filter(item => !item.disabled), { select }) } // ---------- Side button (보조기능 전체보기 / 명령어 전체보기) ---------- diff --git a/packages/sw/src/components/sw-header/components/SWHeaderDocumentTitle.tsx b/packages/sw/src/components/sw-header/components/SWHeaderDocumentTitle.tsx index db8327e..f1f6f77 100644 --- a/packages/sw/src/components/sw-header/components/SWHeaderDocumentTitle.tsx +++ b/packages/sw/src/components/sw-header/components/SWHeaderDocumentTitle.tsx @@ -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) { diff --git a/packages/sw/src/components/sw-header/components/SWHeaderDropdown.tsx b/packages/sw/src/components/sw-header/components/SWHeaderDropdown.tsx index e30b9e7..4ea2b05 100644 --- a/packages/sw/src/components/sw-header/components/SWHeaderDropdown.tsx +++ b/packages/sw/src/components/sw-header/components/SWHeaderDropdown.tsx @@ -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 diff --git a/packages/sw/src/components/sw-header/components/SWHeaderLayerMenu.tsx b/packages/sw/src/components/sw-header/components/SWHeaderLayerMenu.tsx index b6e260f..308bf5c 100644 --- a/packages/sw/src/components/sw-header/components/SWHeaderLayerMenu.tsx +++ b/packages/sw/src/components/sw-header/components/SWHeaderLayerMenu.tsx @@ -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 diff --git a/packages/sw/src/components/sw-header/components/SWHeaderMore.tsx b/packages/sw/src/components/sw-header/components/SWHeaderMore.tsx index b21ac06..887b23f 100644 --- a/packages/sw/src/components/sw-header/components/SWHeaderMore.tsx +++ b/packages/sw/src/components/sw-header/components/SWHeaderMore.tsx @@ -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('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null) diff --git a/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx b/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx index 78db656..c64c2ea 100644 --- a/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx +++ b/packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx @@ -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(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 diff --git a/packages/sw/src/components/sw-tool/SWTool.tsx b/packages/sw/src/components/sw-tool/SWTool.tsx index a25b15a..8dd3420 100644 --- a/packages/sw/src/components/sw-tool/SWTool.tsx +++ b/packages/sw/src/components/sw-tool/SWTool.tsx @@ -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 { } function moveFocus(event: KeyboardEvent) { - const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"] - if (!keys.includes(event.key)) return - const items = Array.from(event.currentTarget.querySelectorAll('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('button[role="radio"]:not(:disabled)')), { vertical: true, select: true }) } export const SWToolGroup = forwardRef(function SWToolGroup( diff --git a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx index 89e6ccd..3179b83 100644 --- a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx +++ b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx @@ -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; onCommand: (c: SWViewerCommand) => void }) { const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) } const onKeyDown = (event: KeyboardEvent) => { - const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"] - if (!keys.includes(event.key)) return - const buttons = Array.from(event.currentTarget.querySelectorAll('[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('[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 (