// 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') // 2026-10-21 (user decision): the Story widths are 1920 … 760 and from 760px up the header shows no More. More stays // the narrow-header behavior (< 760), so one frame is narrowed to 640px to dismiss it. check('More: not shown at the Story widths (760px and up)', await page.locator('.hm-sw-header__more-trigger:visible').count() === 0) await page.locator('.sw-story-frame').first().evaluate(e => { e.style.width = '640px' }) await page.waitForTimeout(250) 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 }