Files
HMDesign/packages/sw/tests/keyboard-dismiss.browser.mjs
T
shkim4 2fc1b3e108 fix(sw): 2026-10-21 SW design review and responsive header without More from 760px
User-approved design review (2026-10-21):
- SW Header: from 760px up nothing moves into More; the five menus stay with their
  titles (titles and values ellipsize), file name shrinks to 96px. Open next to the
  logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for
  configured `controls` headers). Dropdown yellow ring inside the button, black seam.
- Property Bar values as read-only red text; property popup "=" labels, read-only
  black values, per-entry hover with pointer.
- Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot
  guide off by default, Viewer file list without icons, Storybook widths 1920-760.
- Tests: old More expectations (integrated-improvement, keyboard-dismiss,
  footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule;
  functional checks kept.
2026-10-21 17:29:25 +09:00

123 lines
11 KiB
JavaScript

// 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 <new-evidence-dir>
// 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'), '<!doctype html><html lang="ko"><head><meta charset="utf-8"><link rel="icon" href="data:,"><link rel="stylesheet" href="tool-group.css"></head><body style="margin:0;padding:24px;background:#333"><div id="root"></div><script type="module" src="tool-group.js"></script></body></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
}