From 72e163adf4dd56ce862e12b59295ea904d67f959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 15 Jul 2026 00:15:56 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 팝업 레이어 스택을 도입해 Escape와 Tab 소유권을 DOM 구조 추측 대신 등록 순서로 판정한다. 모달, 플로팅 팝업, 리스트 팝업, 드롭다운에 ARIA 시맨틱과 방향키 내비게이션, 포커스 진입과 opener 복원을 넣고 호출부 전반의 ariaLabel을 필수화했다. 웹폰트 미리보기 CSS는 @font-face 블록만 추출해 전역 규칙이 앱에 새어드는 것을 차단한다. --- .../components/main/Grid/PropertiesPanel.tsx | 2 +- .../PropertiesPanel/layer/LayerTabContent.tsx | 1 + .../components/main/Grid/core/Grid.tsx | 2 + .../main/Modal/FloatingPopup.test.tsx | 224 ++++++++++++ .../components/main/Modal/FloatingPopup.tsx | 183 +++++++++- .../components/main/Modal/ListPopup.test.tsx | 147 ++++++++ .../components/main/Modal/ListPopup.tsx | 212 ++++++++++-- .../components/main/Modal/Modal.test.tsx | 212 ++++++++++++ src/renderer/components/main/Modal/Modal.tsx | 89 ++++- .../Modal/content/pickers/ColorPicker.tsx | 2 + .../pickers/CommonListPickerPage.test.tsx | 62 ++++ .../content/pickers/CommonListPickerPage.tsx | 7 + .../pickers/CounterAnimationPicker.tsx | 1 + .../main/Modal/content/pickers/FontPicker.tsx | 20 +- .../Modal/content/pickers/ImagePicker.tsx | 1 + .../Modal/content/pickers/SoundPicker.tsx | 1 + .../settings/ShortcutSettingsModal.tsx | 1 + .../components/main/Modal/popupLayer.ts | 26 ++ .../components/main/Tool/CanvasTool.tsx | 2 + .../components/main/Tool/SettingTool.tsx | 2 + src/renderer/components/main/Tool/TabTool.tsx | 1 + .../components/main/common/Dropdown.test.tsx | 319 ++++++++++++++++++ .../components/main/common/Dropdown.tsx | 184 +++++++++- .../components/shared/PluginElement.tsx | 1 + src/renderer/windows/main/App.tsx | 1 + 25 files changed, 1632 insertions(+), 71 deletions(-) create mode 100644 src/renderer/components/main/Modal/FloatingPopup.test.tsx create mode 100644 src/renderer/components/main/Modal/ListPopup.test.tsx create mode 100644 src/renderer/components/main/Modal/Modal.test.tsx create mode 100644 src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.test.tsx create mode 100644 src/renderer/components/main/Modal/popupLayer.ts create mode 100644 src/renderer/components/main/common/Dropdown.test.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index a9055ea9..7da397f1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -463,7 +463,7 @@ const PropertiesPanel: React.FC = ({ // 모달·포털 메뉴 등 상위 레이어가 열려 있으면 그쪽이 Escape를 소유 if ( document.querySelector( - '[data-dmn-modal-backdrop="true"], [data-dmn-popup-submenu="true"], [role="dialog"]', + '[data-dmn-modal-backdrop="true"], [data-dmn-popup-layer="true"]', ) ) { return; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index 9a910b82..dd874c66 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -807,6 +807,7 @@ const LayerTabContent: React.FC = ({ createPortal( { actions.setContextMenuOpen(false); diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index b7fad0f8..6d609431 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -1841,6 +1841,7 @@ const Grid = ({
{ @@ -2102,6 +2103,7 @@ const Grid = ({
{ setIsGridContextOpen(false); diff --git a/src/renderer/components/main/Modal/FloatingPopup.test.tsx b/src/renderer/components/main/Modal/FloatingPopup.test.tsx new file mode 100644 index 00000000..bec6fd41 --- /dev/null +++ b/src/renderer/components/main/Modal/FloatingPopup.test.tsx @@ -0,0 +1,224 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import FloatingPopup from './FloatingPopup'; + +describe('FloatingPopup focus contract', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.body.innerHTML = ''; + }); + + const renderPopup = async (open: boolean, children: React.ReactNode) => { + await act(async () => { + root.render( + undefined} + > + {children} + , + ); + }); + }; + + it('consumes Tab and cycles focus in both directions', async () => { + await renderPopup( + true, + <> + + + , + ); + + const buttons = Array.from( + document.querySelectorAll( + '[role="dialog"][aria-modal="false"] button', + ), + ); + expect(document.activeElement).toBe(buttons[0]); + + const forward = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(forward); + expect(forward.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(buttons[1]); + + const wrapped = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(wrapped); + expect(wrapped.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(buttons[0]); + + const backward = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(backward); + expect(backward.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(buttons[1]); + }); + + it('consumes Tab and retains the dialog when no child is focusable', async () => { + await renderPopup(true, Content); + const dialog = document.querySelector( + '[role="dialog"][aria-modal="false"]', + ); + expect(document.activeElement).toBe(dialog); + + const event = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(dialog); + }); + + it('respects child autoFocus and restores the opener after close', async () => { + const opener = document.createElement('button'); + document.body.prepend(opener); + opener.focus(); + + await renderPopup( + true, + <> + + + , + ); + expect(document.activeElement?.getAttribute('aria-label')).toBe( + 'Preferred', + ); + + await renderPopup(false, null); + expect(document.activeElement).toBe(opener); + }); + + it('exposes an accessible dialog name', async () => { + await renderPopup(true, ); + + expect( + document.querySelector('[role="dialog"]')?.getAttribute('aria-label'), + ).toBe('Test popup'); + }); + + it('lets only the topmost popup consume Escape', async () => { + const closeBottom = vi.fn(); + const closeTop = vi.fn(); + await act(async () => { + root.render( + <> + + + + + + + , + ); + }); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(closeBottom).not.toHaveBeenCalled(); + expect(closeTop).toHaveBeenCalledTimes(1); + }); + + it('uses opening order instead of DOM order for Escape ownership', async () => { + const closeBottom = vi.fn(); + const closeTop = vi.fn(); + const renderLayers = async (showTop: boolean) => { + await act(async () => { + root.render( + <> + {showTop && ( + + + + )} + + + + , + ); + }); + }; + + await renderLayers(false); + await renderLayers(true); + expect( + Array.from(document.querySelectorAll('[role="dialog"]')) + .map((element) => element.getAttribute('aria-label')) + .filter(Boolean), + ).toEqual(['Top popup', 'Bottom popup']); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(closeBottom).not.toHaveBeenCalled(); + expect(closeTop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/components/main/Modal/FloatingPopup.tsx b/src/renderer/components/main/Modal/FloatingPopup.tsx index 5b0f1bad..3ad6923f 100644 --- a/src/renderer/components/main/Modal/FloatingPopup.tsx +++ b/src/renderer/components/main/Modal/FloatingPopup.tsx @@ -9,9 +9,11 @@ import { autoUpdate, type Placement, } from '@floating-ui/react'; +import { isTopmostPopupLayer, registerPopupLayer } from './popupLayer'; -type FloatingPopupProps = { +interface FloatingPopupBaseProps { open: boolean; + ariaLabel: string; referenceRef?: React.RefObject; placement?: string; offset?: number; @@ -20,17 +22,168 @@ type FloatingPopupProps = { fixedX?: number; fixedY?: number; interactiveRefs?: Array>; - onClose?: () => void; + onClose: () => void; className?: string; children?: React.ReactNode; autoClose?: boolean; closeOnScroll?: boolean; // 스크롤 시 닫을지 여부 portalToBody?: boolean; animate?: boolean; + onKeyDown?: React.KeyboardEventHandler; + focusOriginRef?: React.MutableRefObject; +} + +interface FloatingDialogPopupProps extends FloatingPopupBaseProps { + role?: 'dialog'; + onMenuTab?: never; +} + +interface FloatingMenuPopupProps extends FloatingPopupBaseProps { + role: 'menu'; + onMenuTab: (event: KeyboardEvent) => void; +} + +type FloatingPopupProps = FloatingDialogPopupProps | FloatingMenuPopupProps; + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +const getFocusableElements = (root: HTMLElement) => + Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (element) => + !element.closest('[hidden], [aria-hidden="true"]') && + element.getAttribute('aria-disabled') !== 'true', + ); + +interface FloatingPopupSurfaceProps { + setFloating: (node: HTMLDivElement | null) => void; + style: React.CSSProperties; + className: string; + role: 'dialog' | 'menu'; + ariaLabel: string; + onMenuTab?: (event: KeyboardEvent) => void; + onKeyDown?: React.KeyboardEventHandler; + focusOriginRef?: React.MutableRefObject; + children?: React.ReactNode; +} + +const FloatingPopupSurface = ({ + setFloating, + style, + className, + role, + ariaLabel, + onMenuTab, + onKeyDown, + focusOriginRef, + children, +}: FloatingPopupSurfaceProps) => { + const surfaceRef = useRef(null); + const prevFocusedRef = useRef( + typeof document !== 'undefined' + ? (document.activeElement as HTMLElement | null) + : null, + ); + + // 자식 팝업은 부모 모달의 layout 등록 뒤에 쌓여야 하므로 passive effect 사용 + useEffect(() => { + const surface = surfaceRef.current; + if (!surface) return; + return registerPopupLayer(surface); + }, []); + + useLayoutEffect(() => { + const surface = surfaceRef.current; + if (!surface) return; + if (focusOriginRef) { + focusOriginRef.current = prevFocusedRef.current; + } + if (surface.contains(document.activeElement)) return; + const initialTarget = + role === 'menu' + ? surface.querySelector( + '[role^="menuitem"]:not(:disabled)', + ) ?? surface + : getFocusableElements(surface)[0] ?? surface; + initialTarget.focus(); + }, [focusOriginRef, role]); + + useLayoutEffect(() => { + const prevFocused = prevFocusedRef.current; + return () => { + if (prevFocused && prevFocused.isConnected) { + prevFocused.focus(); + } + }; + }, []); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || event.defaultPrevented) return; + const surface = surfaceRef.current; + if (!isTopmostPopupLayer(surface)) return; + + if (role === 'menu') { + onMenuTab?.(event); + return; + } + + event.preventDefault(); + const focusable = getFocusableElements(surface); + if (focusable.length === 0) { + surface.focus(); + return; + } + + const activeIndex = focusable.indexOf( + document.activeElement as HTMLElement, + ); + const nextIndex = + activeIndex < 0 + ? event.shiftKey + ? focusable.length - 1 + : 0 + : (activeIndex + (event.shiftKey ? -1 : 1) + focusable.length) % + focusable.length; + focusable[nextIndex].focus(); + }; + + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onMenuTab, role]); + + return ( +
{ + setFloating(node); + surfaceRef.current = node; + }} + style={style} + className={className} + role={role} + aria-label={ariaLabel} + aria-modal={role === 'dialog' ? false : undefined} + data-dmn-popup-layer="true" + data-dmn-floating-popup="true" + tabIndex={-1} + onKeyDown={onKeyDown} + > + {children} +
+ ); }; const FloatingPopup = ({ open, + ariaLabel, + role = 'dialog', referenceRef, placement = 'top', offset = 20, @@ -46,6 +199,9 @@ const FloatingPopup = ({ closeOnScroll = false, portalToBody = false, animate = true, + onKeyDown, + onMenuTab, + focusOriginRef, }: FloatingPopupProps) => { const { x, y, refs, strategy, update } = useFloating({ placement: placement as Placement, @@ -70,10 +226,10 @@ const FloatingPopup = ({ if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape' || e.defaultPrevented) return; - if (document.querySelector('[data-dmn-popup-submenu="true"]')) return; + if (!isTopmostPopupLayer(floatingRef.current)) return; // 이 레이어가 소비 — 하위 레이어(페이지·그리드 선택)로 내려가지 않게 e.preventDefault(); - onClose?.(); + onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); @@ -102,7 +258,7 @@ const FloatingPopup = ({ target instanceof Element && !!target.closest('[data-dmn-popup-submenu="true"]'); if (isInsideSubMenu) return; - onClose?.(); + onClose(); }; document.addEventListener('mousedown', onClickAway); @@ -121,7 +277,7 @@ const FloatingPopup = ({ if (!open || !closeOnScroll) return; const handleScroll = () => { - onClose?.(); + onClose(); }; // 캡처 단계에서 모든 스크롤 이벤트 감지 @@ -252,7 +408,7 @@ const FloatingPopup = ({ return; } - onClose?.(); + onClose(); }; // 이벤트 리스너는 document에만 등록 (floatingEl은 동적으로 참조) @@ -288,8 +444,8 @@ const FloatingPopup = ({ } const floatingContent = ( -
{ + { refs.setFloating(node); floatingRef.current = node; }} @@ -299,11 +455,14 @@ const FloatingPopup = ({ top, }} className={`${className}${animate ? ' tooltip-fade-in' : ''}`} - role="dialog" - aria-modal="false" + role={role} + ariaLabel={ariaLabel} + onMenuTab={onMenuTab} + onKeyDown={onKeyDown} + focusOriginRef={focusOriginRef} > {children} -
+ ); // 위치 계산 전후에 렌더 루트가 바뀌지 않도록 필요 시 처음부터 body에 렌더링 diff --git a/src/renderer/components/main/Modal/ListPopup.test.tsx b/src/renderer/components/main/Modal/ListPopup.test.tsx new file mode 100644 index 00000000..bc68ad31 --- /dev/null +++ b/src/renderer/components/main/Modal/ListPopup.test.tsx @@ -0,0 +1,147 @@ +import React, { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import ListPopup, { type ListItem } from './ListPopup'; + +describe('ListPopup keyboard contract', () => { + let host: HTMLDivElement; + let root: Root; + let opener: HTMLButtonElement; + let nextButton: HTMLButtonElement; + let referenceRef: React.RefObject; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + opener = document.createElement('button'); + opener.textContent = 'Open menu'; + nextButton = document.createElement('button'); + nextButton.textContent = 'After menu'; + host = document.createElement('div'); + document.body.append(opener, nextButton, host); + root = createRoot(host); + referenceRef = { current: opener }; + opener.focus(); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + document.body.innerHTML = ''; + }); + + const renderMenu = async (items: ListItem[]) => { + const Harness = () => { + const [open, setOpen] = useState(true); + return ( + setOpen(false)} + items={items} + /> + ); + }; + + await act(async () => root.render()); + }; + + it('uses menu semantics and arrow-key navigation', async () => { + await renderMenu([ + { id: 'first', label: 'First' }, + { id: 'second', label: 'Second' }, + ]); + + const menu = document.querySelector('[role="menu"]'); + const items = Array.from( + document.querySelectorAll('[role="menuitem"]'), + ); + expect(menu?.getAttribute('aria-label')).toBe('Actions'); + expect(document.activeElement).toBe(items[0]); + + await act(async () => { + items[0].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.activeElement).toBe(items[1]); + + await act(async () => { + items[1].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.activeElement).toBe(items[0]); + }); + + it('closes a nested menu on Tab and moves focus past its opener', async () => { + await renderMenu([ + { + id: 'parent', + label: 'Parent', + children: [{ id: 'child', label: 'Child' }], + }, + ]); + + const parent = + document.querySelector('[role="menuitem"]'); + expect(parent).not.toBeNull(); + await act(async () => { + parent?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + }), + ); + }); + + const submenu = document.querySelector( + '[data-dmn-popup-submenu="true"]', + ); + expect(submenu).not.toBeNull(); + expect(document.activeElement?.textContent).toBe('Child'); + + const tab = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + await act(async () => document.dispatchEvent(tab)); + + expect(tab.defaultPrevented).toBe(true); + expect(document.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(nextButton); + }); + + it('keeps Tab navigation inside the opener modal', async () => { + const modal = document.createElement('div'); + modal.dataset.dmnModalBackdrop = 'true'; + const beforeButton = document.createElement('button'); + beforeButton.textContent = 'Before menu'; + modal.append(beforeButton, opener); + document.body.insertBefore(modal, nextButton); + opener.focus(); + + await renderMenu([{ id: 'first', label: 'First' }]); + expect(document.activeElement?.textContent).toBe('First'); + + const tab = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + await act(async () => document.dispatchEvent(tab)); + + expect(tab.defaultPrevented).toBe(true); + expect(document.querySelector('[role="menu"]')).toBeNull(); + expect(document.activeElement).toBe(beforeButton); + }); +}); diff --git a/src/renderer/components/main/Modal/ListPopup.tsx b/src/renderer/components/main/Modal/ListPopup.tsx index 21a04b01..4194a57d 100644 --- a/src/renderer/components/main/Modal/ListPopup.tsx +++ b/src/renderer/components/main/Modal/ListPopup.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; -import { createPortal } from 'react-dom'; +import { createPortal, flushSync } from 'react-dom'; import FloatingPopup from './FloatingPopup'; +import { isTopmostPopupLayer, registerPopupLayer } from './popupLayer'; import { useLenis } from '@hooks/useLenis'; export type ListItem = { @@ -19,9 +20,10 @@ export type ListItem = { interface ListPopupProps { open: boolean; + ariaLabel: string; referenceRef?: React.RefObject; position?: { x: number; y: number }; - onClose?: () => void; + onClose: () => void; items: ListItem[]; onSelect?: (id: string) => void; className?: string; @@ -31,25 +33,102 @@ interface ListPopupProps { maxVisibleItems?: number; } +const DOCUMENT_FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +const getAdjacentFocusTarget = ( + origin: HTMLElement | null, + reverse: boolean, +) => { + const modalScope = origin?.closest( + '[data-dmn-modal-backdrop="true"]', + ); + const focusScope: ParentNode = modalScope ?? document; + const focusable = Array.from( + focusScope.querySelectorAll(DOCUMENT_FOCUSABLE_SELECTOR), + ).filter( + (element) => + !element.closest( + '[hidden], [aria-hidden="true"], [data-dmn-popup-layer="true"]', + ) && element.getAttribute('aria-disabled') !== 'true', + ); + const originIndex = origin ? focusable.indexOf(origin) : -1; + if (originIndex < 0) { + return reverse ? focusable[focusable.length - 1] : focusable[0]; + } + const adjacent = focusable[originIndex + (reverse ? -1 : 1)]; + if (adjacent || !modalScope) return adjacent ?? origin; + return reverse ? focusable[focusable.length - 1] : focusable[0]; +}; + +const getMenuItems = (menu: HTMLElement) => + Array.from( + menu.querySelectorAll( + '[role^="menuitem"]:not(:disabled)', + ), + ); + +const handleMenuNavigation = (event: React.KeyboardEvent) => { + if (event.defaultPrevented) return; + const items = getMenuItems(event.currentTarget); + if (items.length === 0) return; + + const activeIndex = items.indexOf( + document.activeElement as HTMLButtonElement, + ); + let nextIndex: number | null = null; + if (event.key === 'ArrowDown') { + nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % items.length; + } else if (event.key === 'ArrowUp') { + nextIndex = + activeIndex < 0 + ? items.length - 1 + : (activeIndex - 1 + items.length) % items.length; + } else if (event.key === 'Home') { + nextIndex = 0; + } else if (event.key === 'End') { + nextIndex = items.length - 1; + } + + if (nextIndex == null) return; + event.preventDefault(); + items[nextIndex].focus(); +}; + /** 서브메뉴 컴포넌트 (호버 시 표시) */ const SubMenu = ({ + ariaLabel, items, onSelect, onCloseAll, + onMenuTab, maxVisibleItems, anchorRect, + parentItemRef, + focusFirst, onMouseEnter, onMouseLeave, onRequestClose, }: { + ariaLabel: string; items: ListItem[]; onSelect?: (id: string) => void; - onCloseAll?: () => void; + onCloseAll: () => void; + onMenuTab: (event: KeyboardEvent) => void; maxVisibleItems?: number; anchorRect: DOMRect | null; + parentItemRef: React.RefObject; + focusFirst: boolean; onMouseEnter?: () => void; onMouseLeave?: () => void; - onRequestClose?: () => void; + onRequestClose: () => void; }) => { const subMenuRef = useRef(null); const siblingActiveRef = useRef<{ @@ -63,16 +142,29 @@ const SubMenu = ({ top: number; } | null>(null); - // Escape 소유 — 열린 동안 최상위 레이어이므로 소비 후 자신만 닫기 + useLayoutEffect(() => { + const element = subMenuRef.current; + if (!element) return; + return registerPopupLayer(element); + }, [anchorRect]); + + // 최상위 서브메뉴만 키보드 종료를 소유 useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape' || e.defaultPrevented) return; + if (e.defaultPrevented || !isTopmostPopupLayer(subMenuRef.current)) + return; + if (e.key === 'Tab') { + onMenuTab(e); + return; + } + if (e.key !== 'Escape') return; e.preventDefault(); - onRequestClose?.(); + flushSync(onRequestClose); + parentItemRef.current?.focus(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); - }, [onRequestClose]); + }, [onMenuTab, onRequestClose, parentItemRef]); useLayoutEffect(() => { const el = subMenuRef.current; @@ -101,6 +193,14 @@ const SubMenu = ({ ); }, [anchorRect, items.length]); + useLayoutEffect(() => { + if (!focusFirst || !pos) return; + const firstItem = subMenuRef.current + ? getMenuItems(subMenuRef.current)[0] + : null; + firstItem?.focus(); + }, [focusFirst, pos]); + const itemHeight = 26; const itemGap = 4; const separatorCount = items.filter((i) => i.type === 'separator').length; @@ -128,6 +228,19 @@ const SubMenu = ({ if (needsScroll) subLenisRef(node); }} data-dmn-popup-submenu="true" + data-dmn-popup-layer="true" + role="menu" + aria-label={ariaLabel} + tabIndex={-1} + onKeyDown={(event) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + flushSync(onRequestClose); + parentItemRef.current?.focus(); + return; + } + handleMenuNavigation(event); + }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={`fixed z-[60] bg-glass backdrop-glass-popup shadow-elevation-2 rounded-surface p-[4px] flex flex-col gap-[4px] tooltip-fade-in${ @@ -149,6 +262,7 @@ const SubMenu = ({ item={it} onSelect={onSelect} onCloseAll={onCloseAll} + onMenuTab={onMenuTab} siblingActiveRef={siblingActiveRef} hasCheckColumn={hasCheckColumn} /> @@ -163,12 +277,14 @@ const MenuItemRow = ({ item, onSelect, onCloseAll, + onMenuTab, siblingActiveRef, hasCheckColumn = false, }: { item: ListItem; onSelect?: (id: string) => void; - onCloseAll?: () => void; + onCloseAll: () => void; + onMenuTab: (event: KeyboardEvent) => void; /** 형제 항목 중 활성 서브메뉴를 추적하는 ref (즉시 전환용) */ siblingActiveRef?: React.RefObject<{ id: string | null; @@ -181,9 +297,23 @@ const MenuItemRow = ({ const rowRef = useRef(null); const hoverTimerRef = useRef | null>(null); const [rowRect, setRowRect] = useState(null); + const [focusSubMenuOnOpen, setFocusSubMenuOnOpen] = useState(false); const hasChildren = item.children && item.children.length > 0; + const showSubMenu = (focusFirst: boolean) => { + if (!hasChildren || !rowRef.current) return; + setRowRect(rowRef.current.getBoundingClientRect()); + setFocusSubMenuOnOpen(focusFirst); + setSubMenuOpen(true); + if (siblingActiveRef) { + siblingActiveRef.current = { + id: item.id, + close: () => setSubMenuOpen(false), + }; + } + }; + const handleMouseEnter = () => { if (!hasChildren) return; if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); @@ -196,18 +326,7 @@ const MenuItemRow = ({ active.close(); } - hoverTimerRef.current = setTimeout(() => { - if (rowRef.current) { - setRowRect(rowRef.current.getBoundingClientRect()); - } - setSubMenuOpen(true); - if (siblingActiveRef) { - siblingActiveRef.current = { - id: item.id, - close: () => setSubMenuOpen(false), - }; - } - }, delay); + hoverTimerRef.current = setTimeout(() => showSubMenu(false), delay); }; const handleMouseLeave = () => { @@ -229,7 +348,7 @@ const MenuItemRow = ({ // 구분선 (부모 p-[4px] 패딩을 무시하고 전체 폭 사용) if (item.type === 'separator') { return ( -
+
); @@ -238,9 +357,22 @@ const MenuItemRow = ({ const hasCheck = typeof item.checked === 'boolean'; const handleSelect = () => { - if (item.disabled || hasChildren) return; + if (item.disabled) return; + if (hasChildren) { + showSubMenu(false); + return; + } onSelect?.(item.id); - onCloseAll?.(); + onCloseAll(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!hasChildren || !['ArrowRight', 'Enter', ' '].includes(event.key)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + showSubMenu(true); }; return ( @@ -253,7 +385,14 @@ const MenuItemRow = ({ ref={rowRef} type="button" disabled={item.disabled} + role={hasCheck ? 'menuitemcheckbox' : 'menuitem'} + aria-checked={hasCheck ? item.checked : undefined} + aria-disabled={item.disabled || undefined} + aria-haspopup={hasChildren ? 'menu' : undefined} + aria-expanded={hasChildren ? subMenuOpen : undefined} + tabIndex={-1} onClick={handleSelect} + onKeyDown={handleKeyDown} className={`w-full min-w-[96px] h-[26px] px-[8px] rounded-md flex items-center gap-[6px] transition-colors duration-fast ${ item.disabled ? 'opacity-70' @@ -315,11 +454,15 @@ const MenuItemRow = ({ {/* 서브메뉴 */} {hasChildren && subMenuOpen && ( { // 포털로 DOM 중첩이 끊기므로 서브메뉴 진입 시 닫힘 타이머 취소 if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); @@ -340,6 +483,7 @@ const MenuItemRow = ({ const ListPopup = ({ open, + ariaLabel, referenceRef, position, onClose, @@ -350,6 +494,20 @@ const ListPopup = ({ offsetY = 0, maxVisibleItems, }: ListPopupProps) => { + const openerRef = useRef(null); + + const handleMenuTab = (event: KeyboardEvent) => { + event.preventDefault(); + const origin = referenceRef?.current ?? openerRef.current; + const target = getAdjacentFocusTarget(origin, event.shiftKey); + flushSync(onClose); + if (target?.isConnected) { + target.focus(); + } else if (origin?.isConnected) { + origin.focus(); + } + }; + // 일시적 팝업은 상주 크롬(z-30, 패널·미니맵)보다 항상 위 const defaultClassName = 'z-40 bg-glass backdrop-glass-popup shadow-elevation-2 rounded-surface p-[4px] flex flex-col gap-[4px]'; @@ -381,6 +539,10 @@ const ListPopup = ({ return (
diff --git a/src/renderer/components/main/Modal/Modal.test.tsx b/src/renderer/components/main/Modal/Modal.test.tsx new file mode 100644 index 00000000..da8efe44 --- /dev/null +++ b/src/renderer/components/main/Modal/Modal.test.tsx @@ -0,0 +1,212 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import FloatingPopup from './FloatingPopup'; +import Modal from './Modal'; + +describe('Modal focus contract', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.body.innerHTML = ''; + }); + + it('moves focus into the dialog and restores the opener on unmount', async () => { + const opener = document.createElement('button'); + document.body.prepend(opener); + opener.focus(); + + await act(async () => { + root.render( + + + + , + ); + }); + + expect(document.activeElement?.textContent).toBe('First'); + + await act(async () => root.render(null)); + expect(document.activeElement).toBe(opener); + }); + + it('keeps Tab and Shift+Tab inside the top dialog', async () => { + await act(async () => { + root.render( + + + + , + ); + }); + + const buttons = Array.from( + document.querySelectorAll('[role="dialog"] button'), + ); + buttons[1].focus(); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }), + ); + expect(document.activeElement).toBe(buttons[0]); + + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + expect(document.activeElement).toBe(buttons[1]); + }); + + it('does not replace a child autoFocus target', async () => { + await act(async () => { + root.render( + + + + , + ); + }); + + expect(document.activeElement?.getAttribute('aria-label')).toBe( + 'Preferred', + ); + }); + + it('yields keyboard ownership while a popup layer is open', async () => { + const closeModal = vi.fn(); + const closePopup = vi.fn(); + await act(async () => { + root.render( + + + , + ); + }); + await act(async () => { + root.render( + + + + + + , + ); + }); + + const popupAction = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent === 'Popup action', + ); + popupAction?.focus(); + const event = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(popupAction); + + const escape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(escape); + expect(escape.defaultPrevented).toBe(true); + expect(closePopup).toHaveBeenCalledTimes(1); + expect(closeModal).not.toHaveBeenCalled(); + }); + + it('owns Escape when opened after an existing inline popup', async () => { + const closePopup = vi.fn(); + const closeModal = vi.fn(); + const popup = ( + + + + ); + + await act(async () => root.render(<>{popup})); + await act(async () => { + root.render( + <> + {popup} + + + + , + ); + }); + + const escape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(escape); + + expect(escape.defaultPrevented).toBe(true); + expect(closePopup).not.toHaveBeenCalled(); + expect(closeModal).toHaveBeenCalledTimes(1); + }); + + it('lets a child popup mounted with the modal own Escape', async () => { + const closePopup = vi.fn(); + const closeModal = vi.fn(); + await act(async () => { + root.render( + + + + + , + ); + }); + + const escape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(escape); + + expect(escape.defaultPrevented).toBe(true); + expect(closePopup).toHaveBeenCalledTimes(1); + expect(closeModal).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/main/Modal/Modal.tsx b/src/renderer/components/main/Modal/Modal.tsx index caa4a294..4fabd13c 100644 --- a/src/renderer/components/main/Modal/Modal.tsx +++ b/src/renderer/components/main/Modal/Modal.tsx @@ -1,5 +1,23 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useLayoutEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; +import { isTopmostPopupLayer, registerPopupLayer } from './popupLayer'; + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +const getFocusableElements = (root: HTMLElement) => + Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (element) => + !element.closest('[hidden], [aria-hidden="true"]') && + element.getAttribute('aria-disabled') !== 'true', + ); interface ModalProps { onClick?: () => void; @@ -31,6 +49,21 @@ const Modal = ({ onCloseRef.current = onClick; }); + // 열기 전 포커스를 첫 렌더 시점에 캡처 — passive effect는 자식 autoFocus· + // 자식 effect 이후에 실행돼 opener 대신 모달 내부 요소를 잡는 오염이 생긴다. + // ref 초기화는 자식 마운트 전에 1회만 실행되므로 opener가 정확히 잡힘 + const prevFocusedRef = useRef( + typeof document !== 'undefined' + ? (document.activeElement as HTMLElement | null) + : null, + ); + + useLayoutEffect(() => { + const backdrop = backdropRef.current; + if (!backdrop) return; + return registerPopupLayer(backdrop); + }, []); + useEffect(() => { const reset = () => { closeFromBackdropRef.current = false; @@ -43,9 +76,17 @@ const Modal = ({ }; }, []); + // 자식 autoFocus를 존중하고, 지정이 없으면 첫 조작 요소로 포커스 이동 + useLayoutEffect(() => { + const backdrop = backdropRef.current; + if (!backdrop || backdrop.contains(document.activeElement)) return; + const initialTarget = getFocusableElements(backdrop)[0] ?? backdrop; + initialTarget.focus(); + }, []); + // 닫힐 때 열기 전 포커스 복원 (요소가 아직 문서에 연결된 경우만) useEffect(() => { - const prevFocused = document.activeElement as HTMLElement | null; + const prevFocused = prevFocusedRef.current; return () => { if (prevFocused && prevFocused.isConnected) { prevFocused.focus(); @@ -53,22 +94,41 @@ const Modal = ({ }; }, []); - // Escape 닫기 — 레이어 소유권: 위 겹(팝업·서브메뉴·플로팅 피커)이 있으면 양보하고 - // 최상위 모달(마지막 마운트 백드롭)만 소비. 한 번에 한 겹씩 닫힘 + // 최상위 모달만 Escape와 Tab 포커스 순환을 소유 useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape' || e.defaultPrevented) return; + if (e.defaultPrevented) return; + if (!isTopmostPopupLayer(backdropRef.current)) return; + + if (e.key === 'Tab') { + const backdrop = backdropRef.current; + if (!backdrop) return; + const focusable = getFocusableElements(backdrop); + if (focusable.length === 0) { + e.preventDefault(); + backdrop.focus(); + return; + } + + const active = document.activeElement; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!backdrop.contains(active)) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } else if (e.shiftKey && (active === first || active === backdrop)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + return; + } + + if (e.key !== 'Escape') return; // 키 리스닝 중엔 양보 — Escape는 리스닝 취소로 예약됨 (raw input 레이스 포함) if (window.__dmn_isKeyListening) return; - // 서브메뉴·플러그인 드롭다운이 위에 떠 있으면 그쪽이 소유 - if (document.querySelector('[data-dmn-popup-submenu="true"]')) return; - // FloatingPopup 계열(피커·컨텍스트 메뉴)이 위에 떠 있으면 그쪽이 소유 - if (document.querySelector('[role="dialog"][aria-modal="false"]')) return; - // 모달 스택에서 최상위만 소비 - const backdrops = document.querySelectorAll( - '[data-dmn-modal-backdrop="true"]', - ); - if (backdrops[backdrops.length - 1] !== backdropRef.current) return; e.preventDefault(); onCloseRef.current?.(); }; @@ -108,6 +168,7 @@ const Modal = ({ role="dialog" aria-modal="true" aria-label={ariaLabel} + tabIndex={-1} className="fixed top-[30px] bottom-[60px] left-0 right-0 flex items-center justify-center z-50" onPointerDown={handleBackdropPointerDown} onClick={handleBackdropClick} diff --git a/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx b/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx index ad641eeb..fb543f9b 100644 --- a/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx @@ -105,6 +105,7 @@ const ColorPickerWrapper = ({ portalToBody = false, closeOnScroll = false, }: ColorPickerWrapperProps) => { + const { t } = useTranslation(); const initialMode = solidOnly ? MODES.solid : isGradientColor(color) @@ -764,6 +765,7 @@ const ColorPickerWrapper = ({ return ( ({ + useLenis: () => ({ + scrollContainerRef: { current: null }, + lenisInstance: { current: null }, + }), +})); + +describe('CommonListPickerPage keyboard contract', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.body.innerHTML = ''; + }); + + it('returns to the parent page when Escape is pressed in search', async () => { + const onBack = vi.fn(); + await act(async () => { + root.render( + undefined} + searchPlaceholder="Search fonts" + items={[]} + renderItem={() => null} + emptyText="Empty" + onAdd={() => undefined} + addLabel="Add" + pageTitle="Fonts" + onBack={onBack} + />, + ); + }); + + const search = host.querySelector('input'); + expect(search?.getAttribute('aria-label')).toBe('Search fonts'); + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + await act(async () => search?.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(true); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx b/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx index 3dd5c662..764cb117 100644 --- a/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx +++ b/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx @@ -119,7 +119,14 @@ export default function CommonListPickerPage({ type="text" value={searchQuery} onChange={(event) => onSearchQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + onBack(); + }} placeholder={searchPlaceholder} + aria-label={searchPlaceholder} className="w-full h-[30px] pl-[30px] pr-[10px] bg-inset rounded-surface text-fg text-body placeholder-fg-faint focus:shadow-focus-ring outline-none transition-shadow duration-fast" />
diff --git a/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx b/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx index 3213db62..2b470d52 100644 --- a/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/CounterAnimationPicker.tsx @@ -320,6 +320,7 @@ const CounterAnimationPicker = ({ {menu.menuKey !== null && ( { } if (font.type === 'web' && font.cssContent) { - // 웹폰트 CSS에서 font-family를 preview 이름으로 교체 - return font.cssContent.replace( - /font-family:\s*['"]?([^'";]+)['"]?\s*;/i, - `font-family: '${previewFontFamily}';`, - ); + // @font-face 블록만 추출해 미리보기 이름으로 치환 — 원문 전체를 주입하면 + // 블록 밖 전역 규칙(body{display:none} 등)과 다른 face까지 앱에 새어든다. + // 저장 경로(useFontLibrary)의 validator와 동일한 추출기를 재사용 + return buildDraftPreviewCss(font.cssContent, previewFontFamily) || null; } return null; @@ -425,6 +427,7 @@ const FontPicker = ({ {addMenuPosition !== null && ( setAddMenuPosition(null)} @@ -445,6 +448,7 @@ const FontPicker = ({ {menu.menuKey !== null && ( setWebFontModal(null)} - ariaLabel="로딩 중..." + ariaLabel={t('propertiesPanel.loading')} >
event.stopPropagation()} >

- 로딩 중... + {t('propertiesPanel.loading')}

diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx index 836fe15d..11f279fc 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx @@ -172,6 +172,7 @@ const ImagePicker = ({ return ( { if (isListening) { setError(null); diff --git a/src/renderer/components/main/Modal/popupLayer.ts b/src/renderer/components/main/Modal/popupLayer.ts new file mode 100644 index 00000000..85fcdb0a --- /dev/null +++ b/src/renderer/components/main/Modal/popupLayer.ts @@ -0,0 +1,26 @@ +const popupLayerStack: HTMLElement[] = []; + +const removePopupLayer = (element: HTMLElement) => { + const index = popupLayerStack.lastIndexOf(element); + if (index >= 0) popupLayerStack.splice(index, 1); +}; + +const removeDisconnectedLayers = () => { + for (let index = popupLayerStack.length - 1; index >= 0; index -= 1) { + if (!popupLayerStack[index].isConnected) { + popupLayerStack.splice(index, 1); + } + } +}; + +export const registerPopupLayer = (element: HTMLElement) => { + removePopupLayer(element); + popupLayerStack.push(element); + return () => removePopupLayer(element); +}; + +export const isTopmostPopupLayer = (element: HTMLElement | null) => { + if (!element) return false; + removeDisconnectedLayers(); + return popupLayerStack[popupLayerStack.length - 1] === element; +}; diff --git a/src/renderer/components/main/Tool/CanvasTool.tsx b/src/renderer/components/main/Tool/CanvasTool.tsx index 508bbcc6..a0a818d5 100644 --- a/src/renderer/components/main/Tool/CanvasTool.tsx +++ b/src/renderer/components/main/Tool/CanvasTool.tsx @@ -144,6 +144,7 @@ const CanvasTool = ({
} onClose={() => setIsAddPopupOpen(false)} items={[ @@ -167,6 +168,7 @@ const CanvasTool = ({ /> } onClose={() => setIsResetPopupOpen(false)} items={[ diff --git a/src/renderer/components/main/Tool/SettingTool.tsx b/src/renderer/components/main/Tool/SettingTool.tsx index 2187bced..a36d2062 100644 --- a/src/renderer/components/main/Tool/SettingTool.tsx +++ b/src/renderer/components/main/Tool/SettingTool.tsx @@ -276,6 +276,7 @@ SettingToolProps) => {
setIsExportImportOpen(false)} items={[ @@ -363,6 +364,7 @@ SettingToolProps) => {
setIsExtrasOpen(false)} items={menuItems} diff --git a/src/renderer/components/main/Tool/TabTool.tsx b/src/renderer/components/main/Tool/TabTool.tsx index 6b8a354e..4235b43a 100644 --- a/src/renderer/components/main/Tool/TabTool.tsx +++ b/src/renderer/components/main/Tool/TabTool.tsx @@ -54,6 +54,7 @@ const TabTool = () => { setIsPopupOpen(false)} diff --git a/src/renderer/components/main/common/Dropdown.test.tsx b/src/renderer/components/main/common/Dropdown.test.tsx new file mode 100644 index 00000000..6e9abaf4 --- /dev/null +++ b/src/renderer/components/main/common/Dropdown.test.tsx @@ -0,0 +1,319 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Modal from '../Modal/Modal'; +import Dropdown from './Dropdown'; + +class ResizeObserverStub { + observe() {} + disconnect() {} +} + +describe('Dropdown keyboard contract', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + host.remove(); + document.body.innerHTML = ''; + }); + + it('opens with ArrowDown, moves between options, and selects with Enter', async () => { + const onChange = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + + const trigger = host.querySelector('button'); + trigger?.focus(); + await act(async () => { + trigger?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + }); + + let options = Array.from( + document.querySelectorAll('[role="option"]'), + ); + expect(document.activeElement).toBe(options[0]); + + await act(async () => { + options[0].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + }); + options = Array.from( + document.querySelectorAll('[role="option"]'), + ); + expect(document.activeElement).toBe(options[1]); + + await act(async () => { + options[1].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(onChange).toHaveBeenCalledWith('two'); + expect(document.querySelector('[role="listbox"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('wraps ArrowUp from the first option to the last option', async () => { + await act(async () => { + root.render( + undefined} + />, + ); + }); + + const trigger = host.querySelector('button'); + trigger?.focus(); + await act(async () => { + trigger?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp', + bubbles: true, + cancelable: true, + }), + ); + }); + + const options = Array.from( + document.querySelectorAll('[role="option"]'), + ); + expect(document.activeElement).toBe(options[0]); + await act(async () => { + options[0].dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.activeElement).toBe(options[1]); + }); + + it('returns Tab navigation to the trigger order inside a modal', async () => { + await act(async () => { + root.render( +
+ + undefined} + /> + +
, + ); + }); + + const trigger = host.querySelectorAll('button')[1]; + await act(async () => { + trigger.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + }); + + const option = document.querySelector('[role="option"]'); + await act(async () => { + option?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(document.activeElement?.textContent).toBe('After'); + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); + + it('keeps Tab navigation inside a parent popup layer', async () => { + await act(async () => { + root.render( + <> + +
+ + undefined} + /> + +
+ + , + ); + }); + + const trigger = host.querySelector( + '[aria-haspopup="listbox"]', + ); + await act(async () => { + trigger?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + }); + + const option = document.querySelector('[role="option"]'); + await act(async () => { + option?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(document.activeElement?.textContent).toBe('Inside after'); + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); + + it('closes and restores trigger focus when open options become empty', async () => { + const renderOptions = async ( + options: Array<{ label: string; value: string }>, + ) => { + await act(async () => { + root.render( + undefined} />, + ); + }); + }; + + await renderOptions([ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + ]); + const trigger = host.querySelector( + '[aria-haspopup="listbox"]', + ); + await act(async () => { + trigger?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.querySelector('[role="listbox"]')).not.toBeNull(); + + await renderOptions([]); + + expect(document.querySelector('[role="listbox"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(trigger?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('closes itself before its parent modal on Escape', async () => { + const closeModal = vi.fn(); + await act(async () => { + root.render( + + undefined} + /> + , + ); + }); + + const trigger = document.querySelector( + '[aria-haspopup="listbox"]', + ); + await act(async () => { + trigger?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.querySelector('[role="listbox"]')).not.toBeNull(); + + await act(async () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(document.querySelector('[role="listbox"]')).toBeNull(); + expect(closeModal).not.toHaveBeenCalled(); + + await act(async () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(closeModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/components/main/common/Dropdown.tsx b/src/renderer/components/main/common/Dropdown.tsx index 001f4a9c..54e0f58d 100644 --- a/src/renderer/components/main/common/Dropdown.tsx +++ b/src/renderer/components/main/common/Dropdown.tsx @@ -1,11 +1,13 @@ import React, { useState, useRef, + useId, useCallback, useEffect, useLayoutEffect, } from 'react'; import { createPortal } from 'react-dom'; +import { isTopmostPopupLayer, registerPopupLayer } from '../Modal/popupLayer'; interface DropdownOption { label: string; @@ -56,21 +58,67 @@ const Dropdown: React.FC = ({ const [anchor, setAnchor] = useState(null); // null이면 아직 미실측 — 히든 렌더 후 layout effect에서 확정 const [menuPos, setMenuPos] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); const ref = useRef(null); const buttonRef = useRef(null); const menuRef = useRef(null); + const optionRefs = useRef>([]); + const menuId = useId(); + + const selectedIndex = options.findIndex((option) => option.value === value); + + const openMenu = useCallback( + (preferredIndex?: number) => { + const button = buttonRef.current; + if (!button || disabled || options.length === 0) return; + setAnchor(button.getBoundingClientRect()); + setMenuPos(null); + const fallbackIndex = selectedIndex >= 0 ? selectedIndex : 0; + setActiveIndex( + Math.min( + Math.max(preferredIndex ?? fallbackIndex, 0), + options.length - 1, + ), + ); + setOpen(true); + }, + [disabled, options.length, selectedIndex], + ); // 메뉴는 body로 포털 — 패널/모달의 backdrop-filter·mask 아래에서는 // 중첩 backdrop-blur가 무력화되므로 backdrop root 밖에서 그린다 const toggleOpen = () => { - if (!open && buttonRef.current) { - // 좌표 계산은 실측 후 layout effect에서 — 여기선 앵커만 캡처 - setAnchor(buttonRef.current.getBoundingClientRect()); - setMenuPos(null); + if (open) { + setOpen(false); + return; } - setOpen((prev) => !prev); + openMenu(); }; + const closeAndFocusTrigger = useCallback(() => { + setOpen(false); + buttonRef.current?.focus(); + }, []); + + const selectOption = useCallback( + (index: number) => { + const option = options[index]; + if (!option) return; + onChange(option.value); + closeAndFocusTrigger(); + }, + [closeAndFocusTrigger, onChange, options], + ); + + const moveActiveOption = useCallback( + (nextIndex: number) => { + if (options.length === 0) return; + const normalized = (nextIndex + options.length) % options.length; + setActiveIndex(normalized); + }, + [options.length], + ); + // 실측 기반 확정 배치 — 히든 렌더한 메뉴의 레이아웃 크기(offsetWidth/Height, // transform 무관)를 재서 최종 픽셀 좌표를 한 번에 확정하고 표시. // 보이는 첫 프레임이 곧 확정 위치라 중간 위치가 비칠 수 없고, @@ -135,6 +183,24 @@ const Dropdown: React.FC = ({ place(); }, [open, menuPos, place]); + // 열린 뒤 옵션이 사라지면 빈 포털을 남기지 않고 트리거로 복귀 + useLayoutEffect(() => { + if (!open || options.length > 0) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + closeAndFocusTrigger(); + }, [closeAndFocusTrigger, open, options.length]); + + useLayoutEffect(() => { + if (!open || !menuPos || activeIndex < 0) return; + optionRefs.current[activeIndex]?.focus(); + }, [activeIndex, menuPos, open]); + + useLayoutEffect(() => { + const menu = menuRef.current; + if (!open || !menu) return; + return registerPopupLayer(menu); + }, [anchor, open]); + // 열린 동안 내용 크기 변화(비동기 옵션 로드 등) 시 클램프·플립 재계산 useEffect(() => { if (!open) return; @@ -156,12 +222,90 @@ const Dropdown: React.FC = ({ if (!open) return; const handleKey = (event: KeyboardEvent) => { if (event.key !== 'Escape' || event.defaultPrevented) return; + if (!isTopmostPopupLayer(menuRef.current)) return; event.preventDefault(); - setOpen(false); + closeAndFocusTrigger(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); - }, [open]); + }, [closeAndFocusTrigger, open]); + + const handleTriggerKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + event.preventDefault(); + const preferredIndex = + selectedIndex >= 0 + ? selectedIndex + : event.key === 'ArrowUp' + ? options.length - 1 + : 0; + if (open) { + moveActiveOption(activeIndex + (event.key === 'ArrowDown' ? 1 : -1)); + } else { + openMenu(preferredIndex); + } + }; + + const handleOptionKeyDown = ( + event: React.KeyboardEvent, + index: number, + ) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + moveActiveOption(index + 1); + break; + case 'ArrowUp': + event.preventDefault(); + moveActiveOption(index - 1); + break; + case 'Home': + event.preventDefault(); + setActiveIndex(0); + break; + case 'End': + event.preventDefault(); + setActiveIndex(options.length - 1); + break; + case 'Enter': + case ' ': + event.preventDefault(); + selectOption(index); + break; + case 'Tab': { + event.preventDefault(); + const selector = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + const modalScope = buttonRef.current?.closest( + '[data-dmn-modal-backdrop="true"]', + ); + const popupScope = buttonRef.current?.closest( + '[data-dmn-popup-layer="true"]', + ); + const focusScope: ParentNode = popupScope ?? modalScope ?? document; + const tabStops = Array.from( + focusScope.querySelectorAll(selector), + ).filter( + (element) => + !menuRef.current?.contains(element) && + !element.closest('[hidden], [aria-hidden="true"]'), + ); + const triggerIndex = buttonRef.current + ? tabStops.indexOf(buttonRef.current) + : -1; + const nextIndex = event.shiftKey ? triggerIndex - 1 : triggerIndex + 1; + const wrappedIndex = event.shiftKey ? tabStops.length - 1 : 0; + setOpen(false); + ( + tabStops[nextIndex] ?? + (popupScope || modalScope + ? tabStops[wrappedIndex] + : buttonRef.current) + )?.focus(); + break; + } + } + }; useEffect(() => { if (!open) return; @@ -202,6 +346,9 @@ const Dropdown: React.FC = ({
= ({ 옵션 없음
) : ( - options.map((opt) => ( + options.map((opt, index) => ( @@ -252,10 +404,14 @@ const Dropdown: React.FC = ({