From 0f9e9b0e4ca978ac738a617e5c6f46e09b779a94 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:46 +0900 Subject: [PATCH] test(sw): cover roving focus wrap and outside pointer dismissal keyboard-dismiss.browser.mjs + fixtures/tool-group.tsx: end-point wrap, Home / End, disabled items and a pointerdown outside that does not move focus, for the callers of the shared helpers (22 checks; same result before the refactor). --- packages/sw/tests/fixtures/tool-group.tsx | 21 ++++ .../sw/tests/keyboard-dismiss.browser.mjs | 117 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 packages/sw/tests/fixtures/tool-group.tsx create mode 100644 packages/sw/tests/keyboard-dismiss.browser.mjs diff --git a/packages/sw/tests/fixtures/tool-group.tsx b/packages/sw/tests/fixtures/tool-group.tsx new file mode 100644 index 0000000..c4b685b --- /dev/null +++ b/packages/sw/tests/fixtures/tool-group.tsx @@ -0,0 +1,21 @@ +// Test-only host for keyboard-dismiss.browser.mjs (2026-10-20): SWToolGroup selection="single" has no current Story +// (SW/Tool was removed in 7f42923). Public @hm-design/sw API only; the host owns the pressed option. +import { useState } from "react" +import { createRoot } from "react-dom/client" +import { SWTheme, SWToolButton, SWToolGroup } from "@hm-design/sw" +import "@hm-design/sw/styles.css" + +const OPTIONS = [{ id: "a", label: "모델" }, { id: "b", label: "배치" }, { id: "c", label: "단면", disabled: true }, { id: "d", label: "상세" }] + +function Host() { + const [selected, setSelected] = useState("b") + return ( + + + {OPTIONS.map(option => setSelected(option.id)}>{option.label})} + + + ) +} + +createRoot(document.getElementById("root")!).render() diff --git a/packages/sw/tests/keyboard-dismiss.browser.mjs b/packages/sw/tests/keyboard-dismiss.browser.mjs new file mode 100644 index 0000000..f35a21a --- /dev/null +++ b/packages/sw/tests/keyboard-dismiss.browser.mjs @@ -0,0 +1,117 @@ +// Roving focus and outside-pointer dismissal of the shared internal helpers (2026-10-20): moveRovingFocus +// (SWToolGroup, SWFooter, SWViewerHeader tabs, SWHomeMenu) and useOutsidePointerDown (SWHeader dropdowns, layer menu, +// document title, More). The existing suites only moved between middle items and closed menus by moving focus, which +// also closes them; this suite checks wrap-around, Home / End, disabled skipping, selection, and a pointerdown outside +// that leaves focus in place. SWToolGroup runs on a test-only fixture; the others on the built Storybook. +// Build Storybook first. Usage: node packages/sw/tests/keyboard-dismiss.browser.mjs +// SW_STORYBOOK_DIR: another built Storybook (e.g. a copy built before a change) instead of storybook-static. +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import http from 'node:http' +import { fileURLToPath } from 'node:url' +import { loadPlaywright } from './playwright-runtime.mjs' + +if (!process.argv[2]) throw new Error('Pass a new evidence directory') +const out = path.resolve(process.argv[2]), site = path.join(out, 'fixture') +await mkdir(site, { recursive: true }) +await createRequire(import.meta.url)('esbuild').build({ + entryPoints: [fileURLToPath(new URL('./fixtures/tool-group.tsx', import.meta.url))], bundle: true, outdir: site, format: 'esm', jsx: 'automatic', minify: true, + loader: { '.ttf': 'file', '.woff': 'file', '.woff2': 'file', '.svg': 'file', '.png': 'file' }, define: { 'process.env.NODE_ENV': '"production"' }, logLevel: 'warning', +}) +await writeFile(path.join(site, 'index.html'), '
') + +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' } +const serve = root => { const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } }); return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server))) } +const [fixtureServer, storybookServer] = await Promise.all([serve(site), serve(path.resolve(process.env.SW_STORYBOOK_DIR ?? 'storybook-static'))]) +const fixtureBase = `http://127.0.0.1:${fixtureServer.address().port}`, base = `http://127.0.0.1:${storybookServer.address().port}` +const report = { checks: [], errors: [] } +const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); if (!ok) console.error('FAIL', name, JSON.stringify(data)) } +const { chromium } = loadPlaywright() +const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }) +report.browser = browser.version() +const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } }) +page.on('pageerror', e => report.errors.push(e.message)) +page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) }) +const story = async id => { + await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`) + await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor(); await page.evaluate(() => document.fonts.ready); await page.mouse.move(1, 1) +} +// Marks the focused element so later keys can be compared with it; returns its descriptor. +const mark = name => name.replace(/\s+/g, '_') +const focused = name => page.evaluate(name => { const e = document.activeElement; if (!e || e === document.body) return null; e.dataset.kbMark = (e.dataset.kbMark ? e.dataset.kbMark + ' ' : '') + name; return (e.getAttribute('aria-label') ?? e.textContent).trim().slice(0, 24) }, mark(name)) +const isMarked = name => page.evaluate(name => (document.activeElement?.dataset.kbMark ?? '').split(' ').includes(name), mark(name)) +// Ends found with Home / End, then: End + forward wraps to the first, Home + backward wraps to the last. +async function roving(tag, first, { forward = 'ArrowRight', backward = 'ArrowLeft', ignored = [] } = {}) { + await first.focus() + await page.keyboard.press('Home'); const head = await focused(`${tag}-head`) + await page.keyboard.press('End'); const tail = await focused(`${tag}-tail`) + check(`${tag}: Home and End reach two different ends`, !!head && !!tail && head !== tail, { head, tail }) + await page.keyboard.press(forward) + check(`${tag}: ${forward} on the last item wraps to the first`, await isMarked(`${tag}-head`), { head, tail, now: await focused('x') }) + await page.keyboard.press(backward) + check(`${tag}: ${backward} on the first item wraps to the last`, await isMarked(`${tag}-tail`), { head, tail, now: await focused('x') }) + for (const key of ignored) { await page.keyboard.press(key); check(`${tag}: ${key} does not move focus`, await isMarked(`${tag}-tail`), await focused('x')) } +} + +try { + // 1. SWToolGroup selection="single" (radio): arrows in both axes, disabled option skipped, focus selects. + await page.goto(fixtureBase); await page.locator('[data-tool-fixture] [role="radiogroup"]').waitFor() + const option = id => page.locator(`[data-tool-fixture] [data-option="${id}"]`) + const selected = () => page.locator('[data-tool-fixture]').getAttribute('data-selected') + const at = async () => page.evaluate(() => document.activeElement?.dataset.option) + await option('b').focus() + const steps = [] + for (const key of ['ArrowRight', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End']) { await page.keyboard.press(key); steps.push([key, await at(), await selected()]) } + const expected = [['ArrowRight', 'd', 'd'], ['ArrowRight', 'a', 'a'], ['ArrowLeft', 'd', 'd'], ['ArrowDown', 'a', 'a'], ['ArrowUp', 'd', 'd'], ['Home', 'a', 'a'], ['End', 'd', 'd']] + check('SWToolGroup radio: skips the disabled option, wraps both ways, ArrowDown / ArrowUp move, Home / End, focus selects', JSON.stringify(steps) === JSON.stringify(expected), { steps, expected }) + check('SWToolGroup radio: aria-checked follows the selection', await option('d').getAttribute('aria-checked') === 'true' && await option('a').getAttribute('aria-checked') === 'false') + + // 2. SWFooter tool row (horizontal only). + await story('sw-footer--full-footer') + await roving('SWFooter tools', page.locator('.hm-sw-footer__slots[role="toolbar"] .hm-sw-footer-tool').first(), { ignored: ['ArrowDown', 'ArrowUp'] }) + + // 3. SWViewerHeader tabs (radio, both axes, focus selects). + await story('sw-viewer--viewer-header') + const tabs = page.locator('.hm-sw-viewer-header__tabs[role="radiogroup"]').first() + await roving('SWViewerHeader tabs', tabs.locator('[role="radio"]:not(:disabled)').first(), { forward: 'ArrowDown', backward: 'ArrowUp' }) + check('SWViewerHeader tabs: the focused tab is the checked one', await page.evaluate(() => document.activeElement?.getAttribute('aria-checked')) === 'true') + + // 4. SWHomeMenu tiles (horizontal only). + await story('sw-header--home-menu-standalone') + await roving('SWHomeMenu tiles', page.locator('.hm-sw-home-menu__item').first(), { ignored: [] }) + + // 5. Outside pointerdown that leaves focus on the trigger: closes the open menu; inside it does not. + const pointerdown = selector => page.evaluate(selector => document.querySelector(selector).dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })), selector) + await story('sw-header--composed-controls') + const host = page.locator('[data-composed-controls]'), menu = () => page.locator('[data-composed-controls] .hm-sw-header__menu-panel, [data-composed-controls] .hm-sw-header__layer-menu') + for (const label of ['레이어', '색상', '선스타일', '문자스타일', '치수스타일']) { + const trigger = host.getByRole('button', { name: label, exact: true }) + await trigger.click() + await pointerdown('[data-composed-controls] :is(.hm-sw-header__menu-panel, .hm-sw-header__layer-menu)') + const keptInside = await menu().count() === 1 + await pointerdown('body') + check(`${label}: pointerdown inside keeps the menu; outside closes it with focus kept on the trigger`, keptInside && await menu().count() === 0 && await trigger.evaluate(e => e === document.activeElement)) + } + await story('sw-header--document-dropdown') + const documentButton = page.locator('[data-callback-log] + * .hm-sw-header__document-button').first() + await documentButton.click() + const documentMenu = () => page.locator('[data-callback-log] + * .hm-sw-header__document-menu, [data-callback-log] + * .hm-sw-header__document-list') + const documentOpen = await documentMenu().count() > 0 && await documentButton.getAttribute('aria-expanded') === 'true' + await pointerdown('body') + check('Document title: outside pointerdown closes the list, focus kept', documentOpen && await documentButton.getAttribute('aria-expanded') === 'false' && await documentButton.evaluate(e => e === document.activeElement), { documentOpen }) + await story('sw-header--responsive-more') + const more = page.locator('.hm-sw-header__more-trigger:visible').first() + await more.click() + const moreOpen = await more.getAttribute('aria-expanded') === 'true' + await pointerdown('body') + check('More: outside pointerdown closes it, focus kept', moreOpen && await more.getAttribute('aria-expanded') === 'false' && await more.evaluate(e => e === document.activeElement), { moreOpen }) + check('no browser console or page errors', report.errors.length === 0, report.errors) +} catch (error) { check('suite completed', false, error.stack) } +finally { + await browser.close(); fixtureServer.close(); storybookServer.close() + report.status = report.checks.every(c => c.status === 'PASS') ? 'PASS' : 'FAIL' + await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2) + '\n') + console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length) + process.exitCode = report.status === 'PASS' ? 0 : 1 +}