Mercurial
comparison design_system/test/catalog_test.js @ 258:60a876c4587a
[ui] Add semantic primitive ownership
Build a layered Zenbu token and sizing system, make authored controls use native-underneath primitives, migrate mrjunejune without imposing visual surfaces, and document/enforce HTML ownership in the catalog and wiki.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Wed, 05 Aug 2026 05:25:40 -0700 |
| parents | |
| children | 667156fcd3e3 |
comparison
equal
deleted
inserted
replaced
| 257:609d3c6aff4e | 258:60a876c4587a |
|---|---|
| 1 const assert = require('node:assert/strict'); | |
| 2 const http = require('node:http'); | |
| 3 const net = require('node:net'); | |
| 4 const path = require('node:path'); | |
| 5 const { spawn } = require('node:child_process'); | |
| 6 | |
| 7 const RUNFILES = process.env.JS_BINARY__RUNFILES; | |
| 8 const WORKSPACE = process.env.JS_BINARY__WORKSPACE; | |
| 9 const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); | |
| 10 const playwrightPath = path.join( | |
| 11 runfilesWorkspace, | |
| 12 'hg-web/e2e/node_modules/playwright-core', | |
| 13 ); | |
| 14 const { chromium } = require(playwrightPath); | |
| 15 | |
| 16 function findFreePort() { | |
| 17 return new Promise((resolve, reject) => { | |
| 18 const server = net.createServer(); | |
| 19 server.once('error', reject); | |
| 20 server.listen(0, '127.0.0.1', () => { | |
| 21 const address = server.address(); | |
| 22 server.close(error => { | |
| 23 if (error) reject(error); | |
| 24 else resolve(String(address.port)); | |
| 25 }); | |
| 26 }); | |
| 27 }); | |
| 28 } | |
| 29 | |
| 30 async function stopProcess(child) { | |
| 31 if (!child || child.exitCode !== null) return; | |
| 32 child.kill('SIGTERM'); | |
| 33 await new Promise(resolve => { | |
| 34 const timer = setTimeout(() => { | |
| 35 if (child.exitCode === null) child.kill('SIGKILL'); | |
| 36 }, 3000); | |
| 37 child.once('exit', () => { | |
| 38 clearTimeout(timer); | |
| 39 resolve(); | |
| 40 }); | |
| 41 }); | |
| 42 } | |
| 43 | |
| 44 async function waitForServer(server, baseUrl, logs) { | |
| 45 const deadline = Date.now() + 15000; | |
| 46 while (Date.now() < deadline) { | |
| 47 if (server.exitCode !== null) { | |
| 48 throw new Error(`Server exited with ${server.exitCode}\n${logs.join('')}`); | |
| 49 } | |
| 50 try { | |
| 51 const response = await fetch(baseUrl); | |
| 52 if (response.ok) return; | |
| 53 } catch { | |
| 54 // Keep waiting. | |
| 55 } | |
| 56 await new Promise(resolve => setTimeout(resolve, 100)); | |
| 57 } | |
| 58 throw new Error(`Server startup timed out\n${logs.join('')}`); | |
| 59 } | |
| 60 | |
| 61 function requestRawPath(port, requestPath) { | |
| 62 return new Promise((resolve, reject) => { | |
| 63 const request = http.request({ | |
| 64 host: '127.0.0.1', | |
| 65 port, | |
| 66 method: 'GET', | |
| 67 path: requestPath, | |
| 68 agent: false, | |
| 69 }, response => { | |
| 70 response.resume(); | |
| 71 response.on('end', () => resolve(response.statusCode)); | |
| 72 }); | |
| 73 request.on('error', reject); | |
| 74 request.end(); | |
| 75 }); | |
| 76 } | |
| 77 | |
| 78 (async () => { | |
| 79 assert.ok(RUNFILES); | |
| 80 assert.ok(WORKSPACE); | |
| 81 const serverBinary = path.join( | |
| 82 runfilesWorkspace, | |
| 83 'design_system/design_system_server', | |
| 84 ); | |
| 85 const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); | |
| 86 const port = await findFreePort(); | |
| 87 const baseUrl = `http://127.0.0.1:${port}`; | |
| 88 const logs = []; | |
| 89 let server; | |
| 90 let browser; | |
| 91 | |
| 92 try { | |
| 93 const invalidServer = spawn(serverBinary, [], { | |
| 94 cwd: runfilesWorkspace, | |
| 95 env: { | |
| 96 ...process.env, | |
| 97 DESIGN_SYSTEM_PORT: 'not-a-port', | |
| 98 }, | |
| 99 stdio: ['ignore', 'pipe', 'pipe'], | |
| 100 }); | |
| 101 const invalidExit = await new Promise(resolve => { | |
| 102 invalidServer.once('exit', (code, signal) => resolve({ code, signal })); | |
| 103 }); | |
| 104 assert.equal(invalidExit.code, 1); | |
| 105 assert.equal(invalidExit.signal, null); | |
| 106 | |
| 107 server = spawn(serverBinary, [], { | |
| 108 cwd: runfilesWorkspace, | |
| 109 env: { | |
| 110 ...process.env, | |
| 111 DESIGN_SYSTEM_PORT: port, | |
| 112 }, | |
| 113 stdio: ['ignore', 'pipe', 'pipe'], | |
| 114 }); | |
| 115 server.stdout.on('data', chunk => logs.push(chunk.toString())); | |
| 116 server.stderr.on('data', chunk => logs.push(chunk.toString())); | |
| 117 await waitForServer(server, baseUrl, logs); | |
| 118 assert.equal(await requestRawPath(port, '/../../MODULE.bazel'), 400); | |
| 119 | |
| 120 for (const route of [ | |
| 121 '/', | |
| 122 '/tokens', | |
| 123 '/icons', | |
| 124 '/components', | |
| 125 '/components/button', | |
| 126 '/components/card', | |
| 127 '/components/alert', | |
| 128 '/components/field', | |
| 129 '/components/notifications', | |
| 130 '/components/stack', | |
| 131 ]) { | |
| 132 const response = await fetch(`${baseUrl}${route}`); | |
| 133 assert.equal(response.status, 200, route); | |
| 134 assert.match( | |
| 135 response.headers.get('content-type') || '', | |
| 136 /^text\/html/, | |
| 137 ); | |
| 138 } | |
| 139 for (const asset of [ | |
| 140 '/styles/tokens.css', | |
| 141 '/styles/reference.css', | |
| 142 '/styles/semantic.css', | |
| 143 '/styles/density.css', | |
| 144 '/styles/themes.css', | |
| 145 '/styles/components.css', | |
| 146 '/styles/elements.css', | |
| 147 '/components/index.js', | |
| 148 '/components/icon.js', | |
| 149 '/catalog.js', | |
| 150 '/catalog.js', | |
| 151 ]) { | |
| 152 const response = await fetch(`${baseUrl}${asset}`); | |
| 153 assert.equal(response.status, 200, asset); | |
| 154 assert.ok((await response.text()).length > 100, asset); | |
| 155 } | |
| 156 | |
| 157 browser = await chromium.launch({ | |
| 158 executablePath: chromiumPath, | |
| 159 headless: true, | |
| 160 args: ['--no-sandbox'], | |
| 161 }); | |
| 162 const context = await browser.newContext({ colorScheme: 'light' }); | |
| 163 const page = await context.newPage(); | |
| 164 const errors = []; | |
| 165 const devtools = await context.newCDPSession(page); | |
| 166 await devtools.send('Runtime.enable'); | |
| 167 devtools.on('Runtime.exceptionThrown', event => { | |
| 168 const exception = event.exceptionDetails; | |
| 169 errors.push( | |
| 170 `${exception.text}: ${exception.exception?.description || ''} ` + | |
| 171 `${exception.url || ''}:${exception.lineNumber + 1}`, | |
| 172 ); | |
| 173 }); | |
| 174 page.on('pageerror', error => errors.push(error.stack || error.message)); | |
| 175 page.on('console', message => { | |
| 176 if (message.type() === 'error') { | |
| 177 const location = message.location(); | |
| 178 errors.push( | |
| 179 `${message.text()} ${location.url || ''}:${location.lineNumber + 1}`, | |
| 180 ); | |
| 181 } | |
| 182 }); | |
| 183 page.on('response', response => { | |
| 184 if (response.status() >= 400) { | |
| 185 errors.push(`${response.status()} ${response.url()}`); | |
| 186 } | |
| 187 }); | |
| 188 | |
| 189 await page.goto(`${baseUrl}/components/button`, { | |
| 190 waitUntil: 'networkidle', | |
| 191 }); | |
| 192 try { | |
| 193 await page.waitForFunction( | |
| 194 () => customElements.get('zen-button') && | |
| 195 customElements.get('zen-story'), | |
| 196 undefined, | |
| 197 { timeout: 5000 }, | |
| 198 ); | |
| 199 } catch (error) { | |
| 200 throw new Error(`${error.message}\n${errors.join('\n')}`); | |
| 201 } | |
| 202 const catalogState = await page.evaluate(async () => { | |
| 203 const links = [ | |
| 204 ...document.querySelectorAll( | |
| 205 '.catalog-nav[aria-label="Components"] a', | |
| 206 ), | |
| 207 ]; | |
| 208 const elements = [...new Set( | |
| 209 links.map(link => link.dataset.zenElement).filter(Boolean), | |
| 210 )]; | |
| 211 const statuses = await Promise.all( | |
| 212 links.map(async link => (await fetch(link.href)).status), | |
| 213 ); | |
| 214 return { | |
| 215 cards: document.querySelectorAll('.component-grid > a').length, | |
| 216 elements, | |
| 217 links: links.length, | |
| 218 missing: elements.filter(name => !customElements.get(name)), | |
| 219 routes: links.map(link => new URL(link.href).pathname), | |
| 220 statuses, | |
| 221 }; | |
| 222 }); | |
| 223 assert.equal(catalogState.cards, 70); | |
| 224 assert.equal(catalogState.links, 70); | |
| 225 assert.equal(catalogState.elements.length, 69); | |
| 226 assert.deepEqual(catalogState.missing, []); | |
| 227 assert.deepEqual( | |
| 228 [...new Set(catalogState.statuses)], | |
| 229 [200], | |
| 230 ); | |
| 231 const primitiveOwnership = await page.evaluate(() => { | |
| 232 const buttonOwners = [ | |
| 233 'zen-alert', | |
| 234 'zen-attachment', | |
| 235 'zen-button', | |
| 236 'zen-button-group', | |
| 237 'zen-calendar', | |
| 238 'zen-carousel', | |
| 239 'zen-combobox', | |
| 240 'zen-command', | |
| 241 'zen-context-menu', | |
| 242 'zen-data-table', | |
| 243 'zen-date-picker', | |
| 244 'zen-dropdown-menu', | |
| 245 'zen-input-group', | |
| 246 'zen-menubar', | |
| 247 'zen-message-scroller', | |
| 248 'zen-navigation-menu', | |
| 249 'zen-notifications', | |
| 250 'zen-sidebar', | |
| 251 'zen-tabs', | |
| 252 'zen-toggle', | |
| 253 'zen-toggle-group', | |
| 254 ].join(','); | |
| 255 const controlOwners = [ | |
| 256 'zen-calendar', | |
| 257 'zen-checkbox', | |
| 258 'zen-combobox', | |
| 259 'zen-command', | |
| 260 'zen-date-picker', | |
| 261 'zen-field', | |
| 262 'zen-form', | |
| 263 'zen-input', | |
| 264 'zen-input-group', | |
| 265 'zen-input-otp', | |
| 266 'zen-native-select', | |
| 267 'zen-radio-group', | |
| 268 'zen-select', | |
| 269 'zen-slider', | |
| 270 'zen-switch', | |
| 271 'zen-textarea', | |
| 272 ].join(','); | |
| 273 return { | |
| 274 buttons: [...document.querySelectorAll('button')].filter(button => | |
| 275 !button.closest(buttonOwners) && | |
| 276 !button.matches('zen-story > header > button') | |
| 277 ).map(button => button.outerHTML), | |
| 278 controls: [ | |
| 279 ...document.querySelectorAll('input, select, textarea'), | |
| 280 ].filter(control => !control.closest(controlOwners)) | |
| 281 .map(control => control.outerHTML), | |
| 282 }; | |
| 283 }); | |
| 284 assert.deepEqual(primitiveOwnership, { | |
| 285 buttons: [], | |
| 286 controls: [], | |
| 287 }); | |
| 288 const brokenCatalogPages = await page.evaluate(routes => { | |
| 289 const failures = []; | |
| 290 const buttonOwners = [ | |
| 291 'zen-alert', | |
| 292 'zen-attachment', | |
| 293 'zen-button', | |
| 294 'zen-button-group', | |
| 295 'zen-calendar', | |
| 296 'zen-carousel', | |
| 297 'zen-combobox', | |
| 298 'zen-command', | |
| 299 'zen-context-menu', | |
| 300 'zen-data-table', | |
| 301 'zen-date-picker', | |
| 302 'zen-dropdown-menu', | |
| 303 'zen-input-group', | |
| 304 'zen-menubar', | |
| 305 'zen-message-scroller', | |
| 306 'zen-navigation-menu', | |
| 307 'zen-notifications', | |
| 308 'zen-sidebar', | |
| 309 'zen-tabs', | |
| 310 'zen-toggle', | |
| 311 'zen-toggle-group', | |
| 312 ].join(','); | |
| 313 for (const route of ['/', '/tokens', '/icons', '/components', ...routes]) { | |
| 314 history.replaceState({}, '', route); | |
| 315 window.dispatchEvent(new PopStateEvent('popstate')); | |
| 316 const visible = [ | |
| 317 ...document.querySelectorAll('[data-catalog-page]:not([hidden])'), | |
| 318 ]; | |
| 319 const expected = route === '/' || route === '/components' | |
| 320 ? 'overview' | |
| 321 : route.slice(route.lastIndexOf('/') + 1); | |
| 322 if (visible.length !== 1 || | |
| 323 visible[0].dataset.catalogPage !== expected || | |
| 324 !visible[0].querySelector('h1')?.textContent.trim()) { | |
| 325 failures.push(route); | |
| 326 continue; | |
| 327 } | |
| 328 if (route.startsWith('/components/')) { | |
| 329 const story = visible[0].querySelector('zen-story'); | |
| 330 const canvas = story?.querySelector('.story-canvas'); | |
| 331 const source = story?.querySelector('pre code'); | |
| 332 if (!story || !canvas?.children.length || | |
| 333 !source?.textContent.trim()) { | |
| 334 failures.push(`${route}:empty-story`); | |
| 335 } | |
| 336 const unownedButtons = [...canvas?.querySelectorAll('button') || []] | |
| 337 .filter(button => !button.closest(buttonOwners)); | |
| 338 if (unownedButtons.length) { | |
| 339 failures.push(`${route}:unowned-button`); | |
| 340 } | |
| 341 } | |
| 342 } | |
| 343 history.replaceState({}, '', '/components/button'); | |
| 344 window.dispatchEvent(new PopStateEvent('popstate')); | |
| 345 return failures; | |
| 346 }, catalogState.routes); | |
| 347 assert.deepEqual(brokenCatalogPages, []); | |
| 348 await page.keyboard.press('/'); | |
| 349 assert.equal( | |
| 350 await page.locator('#componentSearch').evaluate( | |
| 351 input => document.activeElement === input, | |
| 352 ), | |
| 353 true, | |
| 354 ); | |
| 355 await page.locator('#componentSearch').fill('date'); | |
| 356 assert.equal( | |
| 357 await page.locator( | |
| 358 '.catalog-nav[aria-label="Components"] a:not([hidden])', | |
| 359 ).count(), | |
| 360 2, | |
| 361 ); | |
| 362 assert.equal( | |
| 363 await page.locator('.component-grid > a:not([hidden])').count(), | |
| 364 2, | |
| 365 ); | |
| 366 assert.match( | |
| 367 await page.locator('#componentSearchStatus').textContent(), | |
| 368 /^2 components found$/, | |
| 369 ); | |
| 370 await page.locator('#componentSearch').press('Escape'); | |
| 371 assert.equal(await page.locator('#componentSearch').inputValue(), ''); | |
| 372 assert.equal( | |
| 373 await page.locator( | |
| 374 '.catalog-nav[aria-label="Components"] a:not([hidden])', | |
| 375 ).count(), | |
| 376 70, | |
| 377 ); | |
| 378 await page.locator('#componentSearch').fill('stack'); | |
| 379 assert.equal( | |
| 380 await page.locator( | |
| 381 '.catalog-nav[aria-label="Components"] a[href="/components/stack"]', | |
| 382 ).getAttribute('hidden'), | |
| 383 null, | |
| 384 ); | |
| 385 assert.equal( | |
| 386 await page.locator( | |
| 387 '.component-grid > a[href="/components/stack"]', | |
| 388 ).getAttribute('hidden'), | |
| 389 null, | |
| 390 ); | |
| 391 await page.locator('#componentSearch').press('Escape'); | |
| 392 assert.equal( | |
| 393 await page.locator('[data-catalog-page="button"]').isVisible(), | |
| 394 true, | |
| 395 ); | |
| 396 assert.equal( | |
| 397 await page.locator('.catalog-sidebar a[href="/components/button"]').getAttribute('aria-current'), | |
| 398 'page', | |
| 399 ); | |
| 400 assert.match( | |
| 401 await page.locator('zen-story pre').first().textContent(), | |
| 402 /<zen-button>/, | |
| 403 ); | |
| 404 const plainPresentation = await page.evaluate(() => { | |
| 405 const style = document.createElement('style'); | |
| 406 style.textContent = ` | |
| 407 .application-button { | |
| 408 padding: 7px; | |
| 409 border: 3px solid currentColor; | |
| 410 box-shadow: none; | |
| 411 background: transparent; | |
| 412 } | |
| 413 .application-input { | |
| 414 appearance: auto; | |
| 415 padding: 9px; | |
| 416 border: 4px solid currentColor; | |
| 417 background: transparent; | |
| 418 } | |
| 419 `; | |
| 420 document.head.append(style); | |
| 421 const buttonHost = document.createElement('zen-button'); | |
| 422 buttonHost.setAttribute('appearance', 'plain'); | |
| 423 buttonHost.setAttribute('loading', ''); | |
| 424 const button = document.createElement('button'); | |
| 425 button.className = 'application-button'; | |
| 426 button.textContent = 'Plain'; | |
| 427 buttonHost.append(button); | |
| 428 | |
| 429 const card = document.createElement('zen-card'); | |
| 430 card.setAttribute('appearance', 'plain'); | |
| 431 const article = document.createElement('article'); | |
| 432 card.append(article); | |
| 433 | |
| 434 const field = document.createElement('zen-field'); | |
| 435 field.setAttribute('appearance', 'plain'); | |
| 436 const label = document.createElement('label'); | |
| 437 label.textContent = 'Plain field'; | |
| 438 const input = document.createElement('input'); | |
| 439 field.append(label, input); | |
| 440 const inputHost = document.createElement('zen-input'); | |
| 441 inputHost.setAttribute('appearance', 'plain'); | |
| 442 inputHost.setAttribute('size', 'lg'); | |
| 443 const applicationInput = document.createElement('input'); | |
| 444 applicationInput.className = 'application-input'; | |
| 445 inputHost.append(applicationInput); | |
| 446 document.body.append(buttonHost, card, field, inputHost); | |
| 447 | |
| 448 const buttonStyle = getComputedStyle(button); | |
| 449 const cardStyle = getComputedStyle(article); | |
| 450 return { | |
| 451 buttonBackground: buttonStyle.backgroundColor, | |
| 452 buttonBorder: buttonStyle.borderTopWidth, | |
| 453 buttonPadding: buttonStyle.paddingTop, | |
| 454 buttonShadow: buttonStyle.boxShadow, | |
| 455 buttonOpacity: buttonStyle.opacity, | |
| 456 buttonSpinner: getComputedStyle(button, '::before').content, | |
| 457 cardBackground: cardStyle.backgroundColor, | |
| 458 cardBorder: cardStyle.borderTopWidth, | |
| 459 cardShadow: cardStyle.boxShadow, | |
| 460 fieldDisplay: getComputedStyle(field).display, | |
| 461 inputAppearance: getComputedStyle(input).appearance, | |
| 462 plainInputAppearance: getComputedStyle(applicationInput).appearance, | |
| 463 plainInputBorder: getComputedStyle(applicationInput).borderTopWidth, | |
| 464 plainInputPadding: getComputedStyle(applicationInput).paddingTop, | |
| 465 labelFor: label.htmlFor, | |
| 466 inputId: input.id, | |
| 467 }; | |
| 468 }); | |
| 469 assert.deepEqual(plainPresentation, { | |
| 470 buttonBackground: 'rgba(0, 0, 0, 0)', | |
| 471 buttonBorder: '3px', | |
| 472 buttonPadding: '7px', | |
| 473 buttonShadow: 'none', | |
| 474 buttonOpacity: '1', | |
| 475 buttonSpinner: 'none', | |
| 476 cardBackground: 'rgba(0, 0, 0, 0)', | |
| 477 cardBorder: '0px', | |
| 478 cardShadow: 'none', | |
| 479 fieldDisplay: 'contents', | |
| 480 inputAppearance: 'auto', | |
| 481 plainInputAppearance: 'auto', | |
| 482 plainInputBorder: '4px', | |
| 483 plainInputPadding: '9px', | |
| 484 labelFor: plainPresentation.inputId, | |
| 485 inputId: plainPresentation.inputId, | |
| 486 }); | |
| 487 const buttonScale = await page.locator( | |
| 488 '[data-catalog-page="button"] zen-button[size] > button', | |
| 489 ).evaluateAll(buttons => buttons.map(button => { | |
| 490 const host = button.parentElement; | |
| 491 const style = getComputedStyle(button); | |
| 492 const hostStyle = getComputedStyle(host); | |
| 493 return { | |
| 494 fontSize: Number.parseFloat(style.fontSize), | |
| 495 height: button.getBoundingClientRect().height, | |
| 496 paddingInline: Number.parseFloat(style.paddingInlineStart), | |
| 497 size: host.getAttribute('size'), | |
| 498 variableHeight: Number.parseFloat( | |
| 499 hostStyle.getPropertyValue('--zenbu-control-height'), | |
| 500 ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), | |
| 501 }; | |
| 502 })); | |
| 503 assert.deepEqual( | |
| 504 buttonScale.map(item => item.size), | |
| 505 ['xs', 'sm', 'md', 'lg', 'xl'], | |
| 506 ); | |
| 507 for (let index = 1; index < buttonScale.length; index++) { | |
| 508 assert.ok(buttonScale[index].height > buttonScale[index - 1].height); | |
| 509 assert.ok( | |
| 510 buttonScale[index].paddingInline > | |
| 511 buttonScale[index - 1].paddingInline, | |
| 512 ); | |
| 513 assert.ok( | |
| 514 buttonScale[index].fontSize > buttonScale[index - 1].fontSize, | |
| 515 ); | |
| 516 } | |
| 517 for (const item of buttonScale) { | |
| 518 assert.ok( | |
| 519 Math.abs(item.height - item.variableHeight) < 1, | |
| 520 JSON.stringify(buttonScale), | |
| 521 ); | |
| 522 } | |
| 523 const compactScale = await page.evaluate(() => { | |
| 524 document.documentElement.dataset.zenDensity = 'compact'; | |
| 525 return [...document.querySelectorAll( | |
| 526 '[data-catalog-page="button"] zen-button[size] > button', | |
| 527 )] | |
| 528 .map(button => button.getBoundingClientRect().height); | |
| 529 }); | |
| 530 assert.equal(compactScale.length, buttonScale.length); | |
| 531 for (let index = 0; index < compactScale.length; index++) { | |
| 532 assert.ok(compactScale[index] < buttonScale[index].height); | |
| 533 } | |
| 534 await page.evaluate(() => { | |
| 535 delete document.documentElement.dataset.zenDensity; | |
| 536 }); | |
| 537 | |
| 538 await page.emulateMedia({ reducedMotion: 'reduce' }); | |
| 539 assert.equal( | |
| 540 await page.evaluate(() => | |
| 541 getComputedStyle(document.documentElement) | |
| 542 .getPropertyValue('--zenbu-sys-motion-duration-state') | |
| 543 .trim() | |
| 544 ), | |
| 545 '1ms', | |
| 546 ); | |
| 547 await page.emulateMedia({ reducedMotion: 'no-preference' }); | |
| 548 | |
| 549 assert.equal( | |
| 550 await page.locator('zen-button[loading]:not([appearance="plain"])').getAttribute('inert'), | |
| 551 null, | |
| 552 ); | |
| 553 assert.equal( | |
| 554 await page.locator('zen-button[loading]:not([appearance="plain"]) button').getAttribute('aria-disabled'), | |
| 555 'true', | |
| 556 ); | |
| 557 assert.equal( | |
| 558 await page.locator('zen-button[loading]:not([appearance="plain"]) button').getAttribute('aria-busy'), | |
| 559 'true', | |
| 560 ); | |
| 561 await page.evaluate(() => { | |
| 562 window.__loadingClicks = 0; | |
| 563 document.querySelector('zen-button[loading]:not([appearance="plain"]) button') | |
| 564 .addEventListener('click', () => window.__loadingClicks++); | |
| 565 }); | |
| 566 await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate( | |
| 567 button => button.click(), | |
| 568 ); | |
| 569 assert.equal(await page.evaluate(() => window.__loadingClicks), 0); | |
| 570 await page.locator('zen-button[loading]:not([appearance="plain"]) button').focus(); | |
| 571 assert.equal( | |
| 572 await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate( | |
| 573 button => document.activeElement === button, | |
| 574 ), | |
| 575 true, | |
| 576 ); | |
| 577 await page.keyboard.press('Tab'); | |
| 578 assert.equal( | |
| 579 await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate( | |
| 580 button => document.activeElement === button, | |
| 581 ), | |
| 582 false, | |
| 583 ); | |
| 584 const nativeDisabledPreserved = await page.evaluate(async () => { | |
| 585 const wrapper = document.createElement('zen-button'); | |
| 586 const button = document.createElement('button'); | |
| 587 button.disabled = true; | |
| 588 button.textContent = 'Native disabled'; | |
| 589 wrapper.append(button); | |
| 590 document.body.append(wrapper); | |
| 591 await customElements.whenDefined('zen-button'); | |
| 592 wrapper.setAttribute('disabled', ''); | |
| 593 wrapper.removeAttribute('disabled'); | |
| 594 return button.disabled; | |
| 595 }); | |
| 596 assert.equal(nativeDisabledPreserved, true); | |
| 597 const replacementButtonState = await page.evaluate(async () => { | |
| 598 const wrapper = document.createElement('zen-button'); | |
| 599 wrapper.setAttribute('disabled', ''); | |
| 600 const first = document.createElement('button'); | |
| 601 first.textContent = 'First'; | |
| 602 wrapper.append(first); | |
| 603 document.body.append(wrapper); | |
| 604 await new Promise(resolve => setTimeout(resolve)); | |
| 605 const second = document.createElement('button'); | |
| 606 second.textContent = 'Second'; | |
| 607 wrapper.replaceChildren(second); | |
| 608 await new Promise(resolve => setTimeout(resolve)); | |
| 609 return { | |
| 610 firstDisabled: first.disabled, | |
| 611 secondAriaDisabled: second.getAttribute('aria-disabled'), | |
| 612 wrapperInert: wrapper.hasAttribute('inert'), | |
| 613 }; | |
| 614 }); | |
| 615 assert.equal(replacementButtonState.firstDisabled, false); | |
| 616 assert.equal(replacementButtonState.secondAriaDisabled, 'true'); | |
| 617 assert.equal(replacementButtonState.wrapperInert, false); | |
| 618 const modifiedClickAllowed = await page.evaluate(() => { | |
| 619 const link = document.querySelector( | |
| 620 '.catalog-sidebar a[href="/components/card"]', | |
| 621 ); | |
| 622 return link.dispatchEvent(new MouseEvent('click', { | |
| 623 bubbles: true, | |
| 624 cancelable: true, | |
| 625 button: 0, | |
| 626 ctrlKey: true, | |
| 627 })); | |
| 628 }); | |
| 629 assert.equal(modifiedClickAllowed, true); | |
| 630 | |
| 631 await page.goto(`${baseUrl}/icons`, { waitUntil: 'networkidle' }); | |
| 632 const iconCount = await page.locator('#iconGrid figure').count(); | |
| 633 assert.ok(iconCount >= 30); | |
| 634 assert.equal( | |
| 635 await page.locator('#iconGrid figure zen-icon > svg').count(), | |
| 636 iconCount, | |
| 637 ); | |
| 638 const iconFallback = await page.evaluate(() => { | |
| 639 const icon = document.createElement('zen-icon'); | |
| 640 icon.setAttribute('name', 'not-a-real-icon'); | |
| 641 icon.setAttribute('label', 'Missing icon fallback'); | |
| 642 document.body.append(icon); | |
| 643 return { | |
| 644 fallback: icon.dataset.zenIcon, | |
| 645 hidden: icon.getAttribute('aria-hidden'), | |
| 646 label: icon.getAttribute('aria-label'), | |
| 647 role: icon.getAttribute('role'), | |
| 648 }; | |
| 649 }); | |
| 650 assert.deepEqual(iconFallback, { | |
| 651 fallback: 'alert', | |
| 652 hidden: null, | |
| 653 label: 'Missing icon fallback', | |
| 654 role: 'img', | |
| 655 }); | |
| 656 | |
| 657 await page.goto(`${baseUrl}/components/link`, { | |
| 658 waitUntil: 'networkidle', | |
| 659 }); | |
| 660 const linkPrimitive = page.locator('zen-link[effect="paw"]').first(); | |
| 661 assert.equal( | |
| 662 await linkPrimitive.locator( | |
| 663 ':scope > a > zen-icon[name="paw"][data-zen-link-decoration]', | |
| 664 ).count(), | |
| 665 1, | |
| 666 ); | |
| 667 const pawMotion = await linkPrimitive.locator('zen-icon').evaluate(icon => { | |
| 668 const style = getComputedStyle(icon); | |
| 669 const keyframes = icon.getAnimations().some(animation => | |
| 670 animation.animationName === 'zen-paw-step' | |
| 671 ); | |
| 672 return { | |
| 673 animationDuration: style.animationDuration, | |
| 674 color: style.color, | |
| 675 keyframes, | |
| 676 }; | |
| 677 }); | |
| 678 assert.notEqual(pawMotion.animationDuration, '0s'); | |
| 679 assert.notEqual(pawMotion.color, 'rgba(0, 0, 0, 0)'); | |
| 680 assert.equal(pawMotion.keyframes, true); | |
| 681 await page.emulateMedia({ reducedMotion: 'reduce' }); | |
| 682 assert.equal( | |
| 683 await linkPrimitive.locator('zen-icon').evaluate( | |
| 684 icon => getComputedStyle(icon).animationName, | |
| 685 ), | |
| 686 'none', | |
| 687 ); | |
| 688 await page.emulateMedia({ reducedMotion: 'no-preference' }); | |
| 689 | |
| 690 await page.goto(`${baseUrl}/components/text`, { | |
| 691 waitUntil: 'networkidle', | |
| 692 }); | |
| 693 const textScale = await page.locator('zen-text[size] > p').evaluateAll( | |
| 694 texts => texts.map(text => Number.parseFloat( | |
| 695 getComputedStyle(text).fontSize, | |
| 696 )), | |
| 697 ); | |
| 698 assert.equal(textScale.length, 5); | |
| 699 for (let index = 1; index < textScale.length; index++) { | |
| 700 assert.ok(textScale[index] > textScale[index - 1]); | |
| 701 } | |
| 702 | |
| 703 await page.goto(`${baseUrl}/components/heading`, { | |
| 704 waitUntil: 'networkidle', | |
| 705 }); | |
| 706 const headingScale = await page.locator( | |
| 707 'zen-heading[size] > h3', | |
| 708 ).evaluateAll(headings => headings.map(heading => ({ | |
| 709 fontSize: Number.parseFloat(getComputedStyle(heading).fontSize), | |
| 710 weight: Number(getComputedStyle(heading).fontWeight), | |
| 711 }))); | |
| 712 assert.equal(headingScale.length, 5); | |
| 713 assert.ok(headingScale.every(heading => heading.weight >= 700)); | |
| 714 for (let index = 1; index < headingScale.length; index++) { | |
| 715 assert.ok( | |
| 716 headingScale[index].fontSize > headingScale[index - 1].fontSize, | |
| 717 ); | |
| 718 } | |
| 719 | |
| 720 await page.goto(`${baseUrl}/components/box`, { | |
| 721 waitUntil: 'networkidle', | |
| 722 }); | |
| 723 const boxScale = await page.locator('zen-box[padding]').evaluateAll( | |
| 724 boxes => boxes.map(box => Number.parseFloat( | |
| 725 getComputedStyle(box).paddingTop, | |
| 726 )), | |
| 727 ); | |
| 728 assert.equal(boxScale.length, 5); | |
| 729 for (let index = 1; index < boxScale.length; index++) { | |
| 730 assert.ok(boxScale[index] > boxScale[index - 1]); | |
| 731 } | |
| 732 | |
| 733 await page.goto(`${baseUrl}/components/skeleton`, { | |
| 734 waitUntil: 'networkidle', | |
| 735 }); | |
| 736 assert.equal( | |
| 737 await page.locator('zen-skeleton[width="60%"]').evaluate( | |
| 738 skeleton => skeleton.style.getPropertyValue( | |
| 739 '--zenbu-skeleton-width', | |
| 740 ), | |
| 741 ), | |
| 742 '60%', | |
| 743 ); | |
| 744 | |
| 745 await page.goto(`${baseUrl}/tokens`, { waitUntil: 'networkidle' }); | |
| 746 const colorTokenState = await page.evaluate(() => { | |
| 747 const root = document.documentElement; | |
| 748 const value = name => getComputedStyle(root) | |
| 749 .getPropertyValue(`--zen-color-${name}`) | |
| 750 .trim(); | |
| 751 const systemValue = name => getComputedStyle(root) | |
| 752 .getPropertyValue(`--zenbu-sys-${name}`) | |
| 753 .trim(); | |
| 754 const families = [ | |
| 755 'neutral', | |
| 756 'red', | |
| 757 'orange', | |
| 758 'amber', | |
| 759 'green', | |
| 760 'teal', | |
| 761 'blue', | |
| 762 'violet', | |
| 763 'rose', | |
| 764 'brown', | |
| 765 ]; | |
| 766 const steps = [ | |
| 767 '50', | |
| 768 '100', | |
| 769 '200', | |
| 770 '300', | |
| 771 '400', | |
| 772 '500', | |
| 773 '600', | |
| 774 '700', | |
| 775 '800', | |
| 776 '900', | |
| 777 '950', | |
| 778 ]; | |
| 779 const rampsComplete = families.every(family => | |
| 780 steps.every(step => value(`${family}-${step}`)) | |
| 781 ); | |
| 782 const data = Array.from( | |
| 783 { length: 10 }, | |
| 784 (_, index) => value(`data-${index + 1}`), | |
| 785 ); | |
| 786 const materials = [ | |
| 787 'paper', | |
| 788 'washi', | |
| 789 'linen', | |
| 790 'sumi', | |
| 791 'brick', | |
| 792 'persimmon', | |
| 793 'ochre', | |
| 794 'moss', | |
| 795 'patina', | |
| 796 'indigo', | |
| 797 'plum', | |
| 798 'clay', | |
| 799 'wood', | |
| 800 ].map(value); | |
| 801 const lightMuted = systemValue('color-surface-subtle'); | |
| 802 root.dataset.zenTheme = 'dark'; | |
| 803 const darkMuted = systemValue('color-surface-subtle'); | |
| 804 const darkData = Array.from( | |
| 805 { length: 10 }, | |
| 806 (_, index) => value(`data-${index + 1}`), | |
| 807 ); | |
| 808 const themes = ['paper', 'ink', 'playful'].map(theme => { | |
| 809 root.dataset.zenTheme = theme; | |
| 810 const channels = value => value.match(/\d+(?:\.\d+)?/g) | |
| 811 .slice(0, 3) | |
| 812 .map(Number); | |
| 813 const luminance = value => { | |
| 814 const converted = channels(value).map(channel => { | |
| 815 const normalized = channel / 255; | |
| 816 return normalized <= 0.04045 | |
| 817 ? normalized / 12.92 | |
| 818 : ((normalized + 0.055) / 1.055) ** 2.4; | |
| 819 }); | |
| 820 return converted[0] * 0.2126 + | |
| 821 converted[1] * 0.7152 + | |
| 822 converted[2] * 0.0722; | |
| 823 }; | |
| 824 const contrasts = [ | |
| 825 'info', | |
| 826 'success', | |
| 827 'warning', | |
| 828 'danger', | |
| 829 ].map(tone => { | |
| 830 const probe = document.createElement('span'); | |
| 831 probe.style.background = | |
| 832 `var(--zenbu-sys-color-${tone}-background)`; | |
| 833 probe.style.color = | |
| 834 `var(--zenbu-sys-color-${tone}-foreground)`; | |
| 835 document.body.append(probe); | |
| 836 const probeStyle = getComputedStyle(probe); | |
| 837 const foreground = luminance(probeStyle.color); | |
| 838 const background = luminance(probeStyle.backgroundColor); | |
| 839 const contrast = (Math.max(foreground, background) + 0.05) / | |
| 840 (Math.min(foreground, background) + 0.05); | |
| 841 probe.remove(); | |
| 842 return contrast; | |
| 843 }); | |
| 844 return { | |
| 845 canvas: systemValue('color-surface-page'), | |
| 846 contrast: Math.min(...contrasts), | |
| 847 font: getComputedStyle(root) | |
| 848 .getPropertyValue('--zenbu-sys-font-family-ui') | |
| 849 .trim(), | |
| 850 name: theme, | |
| 851 radius: getComputedStyle(root) | |
| 852 .getPropertyValue('--zenbu-sys-radius-container') | |
| 853 .trim(), | |
| 854 }; | |
| 855 }); | |
| 856 delete root.dataset.zenTheme; | |
| 857 return { | |
| 858 darkData, | |
| 859 darkMuted, | |
| 860 data, | |
| 861 lightMuted, | |
| 862 materials, | |
| 863 rampsComplete, | |
| 864 themes, | |
| 865 }; | |
| 866 }); | |
| 867 assert.equal(colorTokenState.rampsComplete, true); | |
| 868 assert.equal(new Set(colorTokenState.materials).size, 13); | |
| 869 assert.equal(new Set(colorTokenState.data).size, 10); | |
| 870 assert.equal(new Set(colorTokenState.darkData).size, 10); | |
| 871 assert.notDeepEqual(colorTokenState.data, colorTokenState.darkData); | |
| 872 assert.equal( | |
| 873 new Set(colorTokenState.themes.map(theme => theme.canvas)).size, | |
| 874 3, | |
| 875 ); | |
| 876 assert.notEqual( | |
| 877 colorTokenState.themes.find(theme => theme.name === 'paper').radius, | |
| 878 colorTokenState.themes.find(theme => theme.name === 'playful').radius, | |
| 879 ); | |
| 880 assert.ok( | |
| 881 colorTokenState.themes.every(theme => theme.contrast >= 4.5), | |
| 882 JSON.stringify(colorTokenState.themes), | |
| 883 ); | |
| 884 assert.notEqual( | |
| 885 colorTokenState.lightMuted, | |
| 886 colorTokenState.darkMuted, | |
| 887 ); | |
| 888 assert.equal(await page.locator('.palette-grid > div').count(), 13); | |
| 889 assert.equal(await page.locator('.data-palette > span').count(), 10); | |
| 890 await page.evaluate(() => { | |
| 891 document.documentElement.dataset.zenTheme = 'playful'; | |
| 892 }); | |
| 893 await page.emulateMedia({ forcedColors: 'active' }); | |
| 894 assert.equal( | |
| 895 await page.evaluate(() => | |
| 896 getComputedStyle(document.documentElement) | |
| 897 .getPropertyValue('--zenbu-sys-color-surface-page') | |
| 898 .trim() | |
| 899 .toLowerCase() | |
| 900 ), | |
| 901 'canvas', | |
| 902 ); | |
| 903 await page.emulateMedia({ forcedColors: 'none' }); | |
| 904 await page.evaluate(() => { | |
| 905 delete document.documentElement.dataset.zenTheme; | |
| 906 }); | |
| 907 | |
| 908 await page.goto(`${baseUrl}/components/field`, { | |
| 909 waitUntil: 'networkidle', | |
| 910 }); | |
| 911 const fieldWiring = await page.locator('zen-field').first().evaluate(field => { | |
| 912 const label = field.querySelector('label'); | |
| 913 const input = field.querySelector('input'); | |
| 914 const help = field.querySelector('small'); | |
| 915 input.checkValidity(); | |
| 916 return { | |
| 917 describedBy: input.getAttribute('aria-describedby'), | |
| 918 helpId: help.id, | |
| 919 inputId: input.id, | |
| 920 invalid: field.hasAttribute('data-invalid'), | |
| 921 labelFor: label.htmlFor, | |
| 922 }; | |
| 923 }); | |
| 924 assert.ok(fieldWiring.inputId); | |
| 925 assert.equal(fieldWiring.labelFor, fieldWiring.inputId); | |
| 926 assert.equal(fieldWiring.describedBy, fieldWiring.helpId); | |
| 927 assert.equal(fieldWiring.invalid, true); | |
| 928 await page.locator('zen-field input').first().focus(); | |
| 929 assert.equal( | |
| 930 await page.locator('zen-field input').first().evaluate( | |
| 931 input => getComputedStyle(input).outlineStyle, | |
| 932 ), | |
| 933 'none', | |
| 934 ); | |
| 935 const replacementField = await page.locator('zen-field').first().evaluate( | |
| 936 async field => { | |
| 937 const label = field.querySelector('label'); | |
| 938 const oldInput = field.querySelector('input'); | |
| 939 const blocker = document.createElement('div'); | |
| 940 blocker.id = 'zen-field-3'; | |
| 941 document.body.append(blocker); | |
| 942 const nextInput = document.createElement('input'); | |
| 943 nextInput.required = true; | |
| 944 oldInput.replaceWith(nextInput); | |
| 945 await new Promise(resolve => setTimeout(resolve)); | |
| 946 oldInput.dispatchEvent(new Event('invalid')); | |
| 947 const help = field.querySelector('small'); | |
| 948 nextInput.id = 'replacement-email'; | |
| 949 help.id = 'replacement-help'; | |
| 950 await new Promise(resolve => setTimeout(resolve)); | |
| 951 return { | |
| 952 describedBy: nextInput.getAttribute('aria-describedby'), | |
| 953 helpId: help.id, | |
| 954 labelFor: label.htmlFor, | |
| 955 nextId: nextInput.id, | |
| 956 blockerId: blocker.id, | |
| 957 }; | |
| 958 }, | |
| 959 ); | |
| 960 assert.ok(replacementField.nextId); | |
| 961 assert.notEqual(replacementField.nextId, replacementField.blockerId); | |
| 962 assert.equal(replacementField.labelFor, replacementField.nextId); | |
| 963 assert.equal(replacementField.describedBy, replacementField.helpId); | |
| 964 | |
| 965 await page.goto(`${baseUrl}/components/alert`, { | |
| 966 waitUntil: 'networkidle', | |
| 967 }); | |
| 968 await page.evaluate(() => { | |
| 969 window.__dismissed = 0; | |
| 970 document.addEventListener('zen-dismiss', () => { | |
| 971 window.__dismissed++; | |
| 972 }); | |
| 973 }); | |
| 974 const dismissible = page.locator('zen-alert[dismissible]'); | |
| 975 assert.equal(await dismissible.getAttribute('role'), 'alert'); | |
| 976 await dismissible.locator('[data-zen-dismiss]').click(); | |
| 977 assert.equal(await dismissible.count(), 0); | |
| 978 assert.equal(await page.evaluate(() => window.__dismissed), 1); | |
| 979 const authoredDismissState = await page.evaluate(async () => { | |
| 980 const alert = document.createElement('zen-alert'); | |
| 981 alert.setAttribute('dismissible', ''); | |
| 982 const message = document.createElement('p'); | |
| 983 message.textContent = 'Authored dismiss control'; | |
| 984 const dismiss = document.createElement('button'); | |
| 985 dismiss.dataset.zenDismiss = ''; | |
| 986 alert.append(message, dismiss); | |
| 987 document.body.append(alert); | |
| 988 await new Promise(resolve => setTimeout(resolve)); | |
| 989 let events = 0; | |
| 990 alert.addEventListener('zen-dismiss', () => events++); | |
| 991 alert.removeAttribute('dismissible'); | |
| 992 dismiss.click(); | |
| 993 return { | |
| 994 connected: alert.isConnected, | |
| 995 events, | |
| 996 }; | |
| 997 }); | |
| 998 assert.equal(authoredDismissState.connected, true); | |
| 999 assert.equal(authoredDismissState.events, 0); | |
| 1000 | |
| 1001 await page.goto(`${baseUrl}/components/accordion`, { | |
| 1002 waitUntil: 'networkidle', | |
| 1003 }); | |
| 1004 const accordion = page.locator('zen-accordion').first(); | |
| 1005 const disclosureIcon = accordion.locator( | |
| 1006 'summary > zen-icon[data-zen-disclosure-icon]', | |
| 1007 ).nth(1); | |
| 1008 const collapsedDisclosure = await disclosureIcon.evaluate(icon => { | |
| 1009 const style = getComputedStyle(icon); | |
| 1010 return { | |
| 1011 duration: style.transitionDuration, | |
| 1012 transform: style.transform, | |
| 1013 }; | |
| 1014 }); | |
| 1015 assert.notEqual(collapsedDisclosure.duration, '0s'); | |
| 1016 await accordion.locator('summary').nth(1).click(); | |
| 1017 await page.waitForTimeout(250); | |
| 1018 assert.notEqual( | |
| 1019 await disclosureIcon.evaluate(icon => getComputedStyle(icon).transform), | |
| 1020 collapsedDisclosure.transform, | |
| 1021 ); | |
| 1022 assert.equal( | |
| 1023 await accordion.locator('details').first().getAttribute('open'), | |
| 1024 null, | |
| 1025 ); | |
| 1026 assert.equal( | |
| 1027 await accordion.locator('details').nth(1).getAttribute('open'), | |
| 1028 '', | |
| 1029 ); | |
| 1030 await accordion.locator('summary').nth(1).press('ArrowUp'); | |
| 1031 assert.equal( | |
| 1032 await accordion.locator('summary').first().evaluate( | |
| 1033 summary => document.activeElement === summary, | |
| 1034 ), | |
| 1035 true, | |
| 1036 ); | |
| 1037 | |
| 1038 await page.goto(`${baseUrl}/components/aspect-ratio`, { | |
| 1039 waitUntil: 'networkidle', | |
| 1040 }); | |
| 1041 const aspectColors = await page.locator('.demo-media').evaluate(media => { | |
| 1042 const style = getComputedStyle(media); | |
| 1043 const probe = document.createElement('span'); | |
| 1044 probe.style.background = 'var(--zenbu-sys-color-surface-subtle)'; | |
| 1045 document.body.append(probe); | |
| 1046 const token = getComputedStyle(probe).backgroundColor; | |
| 1047 probe.remove(); | |
| 1048 return { | |
| 1049 background: style.backgroundColor, | |
| 1050 image: style.backgroundImage, | |
| 1051 token, | |
| 1052 }; | |
| 1053 }); | |
| 1054 assert.equal(aspectColors.image, 'none'); | |
| 1055 assert.equal(aspectColors.background, aspectColors.token); | |
| 1056 | |
| 1057 await page.goto(`${baseUrl}/components/tabs`, { | |
| 1058 waitUntil: 'networkidle', | |
| 1059 }); | |
| 1060 await page.evaluate(() => { | |
| 1061 window.__tabValue = null; | |
| 1062 document.querySelector('zen-tabs').addEventListener( | |
| 1063 'zen-change', | |
| 1064 event => { | |
| 1065 window.__tabValue = event.detail.value; | |
| 1066 }, | |
| 1067 ); | |
| 1068 }); | |
| 1069 await page.locator('zen-tabs [role="tab"]').nth(1).click(); | |
| 1070 assert.equal( | |
| 1071 await page.locator('zen-tabs [role="tab"]').nth(1) | |
| 1072 .getAttribute('aria-selected'), | |
| 1073 'true', | |
| 1074 ); | |
| 1075 assert.equal( | |
| 1076 await page.locator('zen-tabs [role="tabpanel"]').nth(1) | |
| 1077 .getAttribute('hidden'), | |
| 1078 null, | |
| 1079 ); | |
| 1080 assert.equal(await page.evaluate(() => window.__tabValue), 'history'); | |
| 1081 | |
| 1082 await page.goto(`${baseUrl}/components/dialog`, { | |
| 1083 waitUntil: 'networkidle', | |
| 1084 }); | |
| 1085 await page.locator('zen-dialog [data-zen-trigger]').click(); | |
| 1086 assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), ''); | |
| 1087 await page.locator('zen-dialog [data-zen-close]').click(); | |
| 1088 assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), null); | |
| 1089 const dynamicOverlays = await page.evaluate(async () => { | |
| 1090 const ownAction = button => { | |
| 1091 const owner = document.createElement('zen-button'); | |
| 1092 owner.setAttribute('size', 'md'); | |
| 1093 owner.append(button); | |
| 1094 return owner; | |
| 1095 }; | |
| 1096 const dialogHost = document.createElement('zen-dialog'); | |
| 1097 const popoverHost = document.createElement('zen-popover'); | |
| 1098 const tooltipHost = document.createElement('zen-tooltip'); | |
| 1099 const menuHost = document.createElement('zen-dropdown-menu'); | |
| 1100 document.body.append(dialogHost, popoverHost, tooltipHost, menuHost); | |
| 1101 | |
| 1102 const dialogTrigger = document.createElement('button'); | |
| 1103 dialogTrigger.type = 'button'; | |
| 1104 dialogTrigger.dataset.zenTrigger = ''; | |
| 1105 const dialog = document.createElement('dialog'); | |
| 1106 dialogHost.append(ownAction(dialogTrigger), dialog); | |
| 1107 | |
| 1108 const popoverTrigger = document.createElement('button'); | |
| 1109 popoverTrigger.type = 'button'; | |
| 1110 popoverTrigger.dataset.zenTrigger = ''; | |
| 1111 const popover = document.createElement('div'); | |
| 1112 popover.dataset.zenContent = ''; | |
| 1113 popoverHost.append(ownAction(popoverTrigger), popover); | |
| 1114 | |
| 1115 const tooltipTrigger = document.createElement('button'); | |
| 1116 tooltipTrigger.type = 'button'; | |
| 1117 tooltipTrigger.dataset.zenTrigger = ''; | |
| 1118 const tooltip = document.createElement('span'); | |
| 1119 tooltip.dataset.zenContent = ''; | |
| 1120 tooltipHost.append(ownAction(tooltipTrigger), tooltip); | |
| 1121 | |
| 1122 const menuTrigger = document.createElement('button'); | |
| 1123 menuTrigger.type = 'button'; | |
| 1124 menuTrigger.dataset.zenTrigger = ''; | |
| 1125 const menu = document.createElement('div'); | |
| 1126 menu.setAttribute('role', 'menu'); | |
| 1127 const item = document.createElement('button'); | |
| 1128 item.type = 'button'; | |
| 1129 item.setAttribute('role', 'menuitem'); | |
| 1130 menu.append(item); | |
| 1131 menuHost.append(menuTrigger, menu); | |
| 1132 | |
| 1133 await new Promise(resolve => setTimeout(resolve)); | |
| 1134 dialogTrigger.click(); | |
| 1135 const dialogOpen = dialog.open; | |
| 1136 dialog.close(); | |
| 1137 popoverTrigger.click(); | |
| 1138 menuTrigger.click(); | |
| 1139 tooltipTrigger.focus(); | |
| 1140 await new Promise(resolve => setTimeout(resolve, 375)); | |
| 1141 return { | |
| 1142 dialogControlled: Boolean(dialogTrigger.getAttribute('aria-controls')), | |
| 1143 dialogOpen, | |
| 1144 menuOpen: !menu.hidden, | |
| 1145 popoverExpanded: popoverTrigger.getAttribute('aria-expanded'), | |
| 1146 tooltipOpen: !tooltip.hidden, | |
| 1147 }; | |
| 1148 }); | |
| 1149 assert.deepEqual(dynamicOverlays, { | |
| 1150 dialogControlled: true, | |
| 1151 dialogOpen: true, | |
| 1152 menuOpen: true, | |
| 1153 popoverExpanded: 'true', | |
| 1154 tooltipOpen: true, | |
| 1155 }); | |
| 1156 | |
| 1157 await page.goto(`${baseUrl}/components/dropdown-menu`, { | |
| 1158 waitUntil: 'networkidle', | |
| 1159 }); | |
| 1160 const dropdownTrigger = page.locator( | |
| 1161 'zen-dropdown-menu [data-zen-trigger]', | |
| 1162 ); | |
| 1163 await dropdownTrigger.click(); | |
| 1164 assert.equal( | |
| 1165 await page.locator('zen-dropdown-menu [role="menu"]') | |
| 1166 .getAttribute('hidden'), | |
| 1167 null, | |
| 1168 ); | |
| 1169 await page.keyboard.press('Escape'); | |
| 1170 assert.equal( | |
| 1171 await page.locator('zen-dropdown-menu [role="menu"]') | |
| 1172 .getAttribute('hidden'), | |
| 1173 '', | |
| 1174 ); | |
| 1175 assert.equal( | |
| 1176 await dropdownTrigger.evaluate( | |
| 1177 trigger => document.activeElement === trigger, | |
| 1178 ), | |
| 1179 true, | |
| 1180 ); | |
| 1181 await dropdownTrigger.click(); | |
| 1182 await page.locator('zen-dropdown-menu [role="menuitem"]').first().click(); | |
| 1183 assert.equal( | |
| 1184 await dropdownTrigger.evaluate( | |
| 1185 trigger => document.activeElement === trigger, | |
| 1186 ), | |
| 1187 true, | |
| 1188 ); | |
| 1189 | |
| 1190 await page.goto(`${baseUrl}/components/combobox`, { | |
| 1191 waitUntil: 'networkidle', | |
| 1192 }); | |
| 1193 const comboboxInput = page.locator('zen-combobox input'); | |
| 1194 await comboboxInput.fill('seo'); | |
| 1195 assert.equal( | |
| 1196 await page.locator('zen-combobox [role="option"]:not([hidden])').count(), | |
| 1197 1, | |
| 1198 ); | |
| 1199 await comboboxInput.press('ArrowDown'); | |
| 1200 await comboboxInput.press('Enter'); | |
| 1201 assert.equal(await comboboxInput.inputValue(), 'seobeo'); | |
| 1202 assert.equal( | |
| 1203 await page.locator('zen-combobox [role="listbox"]') | |
| 1204 .getAttribute('hidden'), | |
| 1205 '', | |
| 1206 ); | |
| 1207 | |
| 1208 await page.goto(`${baseUrl}/components/date-picker`, { | |
| 1209 waitUntil: 'networkidle', | |
| 1210 }); | |
| 1211 const dateInput = page.locator('zen-date-picker input[type="date"]'); | |
| 1212 assert.equal(await dateInput.getAttribute('aria-hidden'), 'true'); | |
| 1213 assert.equal( | |
| 1214 await dateInput.evaluate(input => getComputedStyle(input).position), | |
| 1215 'absolute', | |
| 1216 ); | |
| 1217 await page.evaluate(() => { | |
| 1218 window.__dateValue = null; | |
| 1219 document.querySelector('zen-date-picker').addEventListener( | |
| 1220 'zen-change', | |
| 1221 event => { | |
| 1222 window.__dateValue = event.detail.value; | |
| 1223 }, | |
| 1224 ); | |
| 1225 }); | |
| 1226 const dateTrigger = page.locator( | |
| 1227 'zen-date-picker [data-zen-date-trigger]', | |
| 1228 ); | |
| 1229 await dateTrigger.click(); | |
| 1230 assert.equal( | |
| 1231 await page.locator('zen-date-picker [data-zen-calendar-panel]') | |
| 1232 .getAttribute('hidden'), | |
| 1233 null, | |
| 1234 ); | |
| 1235 assert.equal( | |
| 1236 await page.locator( | |
| 1237 'zen-date-picker [data-zen-calendar-day]', | |
| 1238 ).count(), | |
| 1239 42, | |
| 1240 ); | |
| 1241 const chosenDate = await page.locator( | |
| 1242 'zen-date-picker [data-zen-calendar-day]:not([data-outside]):not(:disabled)', | |
| 1243 ).nth(14).getAttribute('data-zen-calendar-day'); | |
| 1244 await page.locator( | |
| 1245 `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`, | |
| 1246 ).click(); | |
| 1247 assert.equal(await dateInput.inputValue(), chosenDate); | |
| 1248 assert.equal(await page.evaluate(() => window.__dateValue), chosenDate); | |
| 1249 assert.equal(await dateTrigger.getAttribute('aria-expanded'), 'false'); | |
| 1250 const triggerName = await dateTrigger.evaluate(trigger => | |
| 1251 (trigger.getAttribute('aria-labelledby') || '') | |
| 1252 .split(/\s+/) | |
| 1253 .map(id => document.getElementById(id)?.textContent || '') | |
| 1254 .join(' ') | |
| 1255 ); | |
| 1256 assert.match(triggerName, /Deploy on/); | |
| 1257 assert.equal( | |
| 1258 await page.locator('zen-date-picker > label').getAttribute('for'), | |
| 1259 await dateTrigger.getAttribute('id'), | |
| 1260 ); | |
| 1261 const minimumDate = `${chosenDate.slice(0, 8)}10`; | |
| 1262 await dateInput.evaluate((input, minimum) => { | |
| 1263 input.min = minimum; | |
| 1264 }, minimumDate); | |
| 1265 await page.waitForTimeout(0); | |
| 1266 await dateTrigger.click(); | |
| 1267 const focusedBoundary = page.locator( | |
| 1268 `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`, | |
| 1269 ); | |
| 1270 await focusedBoundary.press('PageUp'); | |
| 1271 assert.equal( | |
| 1272 await page.evaluate(() => | |
| 1273 document.activeElement?.dataset.zenCalendarDay | |
| 1274 ), | |
| 1275 minimumDate, | |
| 1276 ); | |
| 1277 assert.equal( | |
| 1278 await page.locator( | |
| 1279 'zen-date-picker [data-zen-calendar-day][tabindex="0"]:not(:disabled)', | |
| 1280 ).count(), | |
| 1281 1, | |
| 1282 ); | |
| 1283 await page.keyboard.press('Escape'); | |
| 1284 await dateInput.evaluate(input => { | |
| 1285 input.disabled = true; | |
| 1286 }); | |
| 1287 await page.waitForTimeout(0); | |
| 1288 assert.equal(await dateTrigger.isDisabled(), true); | |
| 1289 await dateInput.evaluate(input => { | |
| 1290 input.disabled = false; | |
| 1291 input.readOnly = true; | |
| 1292 }); | |
| 1293 await page.waitForTimeout(0); | |
| 1294 assert.equal(await dateTrigger.isDisabled(), true); | |
| 1295 await dateInput.evaluate(input => { | |
| 1296 input.readOnly = false; | |
| 1297 input.required = true; | |
| 1298 input.value = ''; | |
| 1299 input.dispatchEvent(new Event('input', { bubbles: true })); | |
| 1300 input.reportValidity(); | |
| 1301 }); | |
| 1302 await page.waitForTimeout(0); | |
| 1303 assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'true'); | |
| 1304 assert.equal( | |
| 1305 await dateTrigger.evaluate( | |
| 1306 trigger => document.activeElement === trigger, | |
| 1307 ), | |
| 1308 true, | |
| 1309 ); | |
| 1310 assert.equal( | |
| 1311 await page.locator('zen-date-picker').getAttribute('data-invalid'), | |
| 1312 '', | |
| 1313 ); | |
| 1314 await dateInput.evaluate((input, value) => { | |
| 1315 input.value = value; | |
| 1316 input.dispatchEvent(new Event('input', { bubbles: true })); | |
| 1317 }, chosenDate); | |
| 1318 assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'false'); | |
| 1319 const earlyYear = await page.evaluate(async () => { | |
| 1320 const calendar = document.createElement('zen-calendar'); | |
| 1321 const input = document.createElement('input'); | |
| 1322 input.type = 'date'; | |
| 1323 input.value = '0001-01-01'; | |
| 1324 input.setAttribute('aria-label', 'Early date'); | |
| 1325 calendar.append(input); | |
| 1326 document.body.append(calendar); | |
| 1327 await new Promise(resolve => setTimeout(resolve)); | |
| 1328 return { | |
| 1329 selected: calendar.querySelector( | |
| 1330 '[data-zen-calendar-day][aria-selected="true"]', | |
| 1331 )?.dataset.zenCalendarDay, | |
| 1332 value: input.value, | |
| 1333 }; | |
| 1334 }); | |
| 1335 assert.deepEqual(earlyYear, { | |
| 1336 selected: '0001-01-01', | |
| 1337 value: '0001-01-01', | |
| 1338 }); | |
| 1339 const resetDateState = await page.evaluate(async () => { | |
| 1340 const label = document.createElement('span'); | |
| 1341 label.id = 'reset-date-label'; | |
| 1342 label.textContent = 'Reset date'; | |
| 1343 const form = document.createElement('form'); | |
| 1344 const picker = document.createElement('zen-date-picker'); | |
| 1345 const input = document.createElement('input'); | |
| 1346 input.type = 'date'; | |
| 1347 input.defaultValue = '2026-08-04'; | |
| 1348 input.setAttribute('aria-labelledby', label.id); | |
| 1349 picker.append(input); | |
| 1350 form.append(picker); | |
| 1351 document.body.append(label, form); | |
| 1352 await new Promise(resolve => setTimeout(resolve)); | |
| 1353 input.value = '2026-08-12'; | |
| 1354 input.dispatchEvent(new Event('input', { bubbles: true })); | |
| 1355 form.reset(); | |
| 1356 await new Promise(resolve => setTimeout(resolve)); | |
| 1357 const trigger = picker.querySelector('[data-zen-date-trigger]'); | |
| 1358 return { | |
| 1359 labelledBy: trigger.getAttribute('aria-labelledby'), | |
| 1360 selected: picker.querySelector( | |
| 1361 '[data-zen-calendar-day][aria-selected="true"]', | |
| 1362 )?.dataset.zenCalendarDay, | |
| 1363 value: input.value, | |
| 1364 }; | |
| 1365 }); | |
| 1366 assert.match(resetDateState.labelledBy, /^reset-date-label /); | |
| 1367 assert.deepEqual( | |
| 1368 { | |
| 1369 selected: resetDateState.selected, | |
| 1370 value: resetDateState.value, | |
| 1371 }, | |
| 1372 { | |
| 1373 selected: '2026-08-04', | |
| 1374 value: '2026-08-04', | |
| 1375 }, | |
| 1376 ); | |
| 1377 | |
| 1378 await page.goto(`${baseUrl}/components/input-otp`, { | |
| 1379 waitUntil: 'networkidle', | |
| 1380 }); | |
| 1381 await page.evaluate(() => { | |
| 1382 window.__otpValue = null; | |
| 1383 document.querySelector('zen-input-otp').addEventListener( | |
| 1384 'zen-complete', | |
| 1385 event => { | |
| 1386 window.__otpValue = event.detail.value; | |
| 1387 }, | |
| 1388 ); | |
| 1389 }); | |
| 1390 await page.locator('zen-input-otp input').evaluate(input => { | |
| 1391 input.value = '12a3456'; | |
| 1392 input.dispatchEvent(new Event('input', { bubbles: true })); | |
| 1393 }); | |
| 1394 assert.equal(await page.locator('zen-input-otp input').inputValue(), '123456'); | |
| 1395 assert.equal( | |
| 1396 await page.locator('zen-input-otp [data-zen-otp-slots] > span').count(), | |
| 1397 6, | |
| 1398 ); | |
| 1399 const otp = page.locator('zen-input-otp'); | |
| 1400 await otp.evaluate(host => host.setAttribute('size', 'xs')); | |
| 1401 const compactOtpSize = await otp.locator( | |
| 1402 '[data-zen-otp-slots] > span', | |
| 1403 ).first().evaluate(slot => slot.getBoundingClientRect().width); | |
| 1404 await otp.evaluate(host => host.setAttribute('size', 'xl')); | |
| 1405 const largeOtpSize = await otp.locator( | |
| 1406 '[data-zen-otp-slots] > span', | |
| 1407 ).first().evaluate(slot => slot.getBoundingClientRect().width); | |
| 1408 assert.ok(largeOtpSize > compactOtpSize); | |
| 1409 assert.equal(await page.evaluate(() => window.__otpValue), '123456'); | |
| 1410 | |
| 1411 await page.goto(`${baseUrl}/components/data-table`, { | |
| 1412 waitUntil: 'networkidle', | |
| 1413 }); | |
| 1414 await page.locator('zen-data-table [data-zen-sort="commits"]').click(); | |
| 1415 assert.equal( | |
| 1416 await page.locator('zen-data-table th').nth(1).getAttribute('aria-sort'), | |
| 1417 'ascending', | |
| 1418 ); | |
| 1419 assert.equal( | |
| 1420 await page.locator('zen-data-table tbody tr').first().locator( | |
| 1421 '[data-key="commits"]', | |
| 1422 ).textContent(), | |
| 1423 '12', | |
| 1424 ); | |
| 1425 | |
| 1426 await page.goto(`${baseUrl}/components/carousel`, { | |
| 1427 waitUntil: 'networkidle', | |
| 1428 }); | |
| 1429 await page.evaluate(() => { | |
| 1430 window.__carouselIndex = null; | |
| 1431 document.querySelector('zen-carousel').addEventListener( | |
| 1432 'zen-change', | |
| 1433 event => { | |
| 1434 window.__carouselIndex = event.detail.index; | |
| 1435 }, | |
| 1436 ); | |
| 1437 }); | |
| 1438 await page.locator('zen-carousel [data-zen-next]').click(); | |
| 1439 assert.equal(await page.evaluate(() => window.__carouselIndex), 1); | |
| 1440 assert.equal( | |
| 1441 await page.locator('zen-carousel [data-zen-prev]').isDisabled(), | |
| 1442 false, | |
| 1443 ); | |
| 1444 | |
| 1445 await page.goto(`${baseUrl}/components/resizable`, { | |
| 1446 waitUntil: 'networkidle', | |
| 1447 }); | |
| 1448 const resizeHandle = page.locator('zen-resizable [data-zen-handle]'); | |
| 1449 const initialPercent = Number( | |
| 1450 await resizeHandle.getAttribute('aria-valuenow'), | |
| 1451 ); | |
| 1452 await resizeHandle.focus(); | |
| 1453 await resizeHandle.press('ArrowRight'); | |
| 1454 assert.ok( | |
| 1455 Number(await resizeHandle.getAttribute('aria-valuenow')) > initialPercent, | |
| 1456 ); | |
| 1457 | |
| 1458 await page.goto(`${baseUrl}/components/switch`, { | |
| 1459 waitUntil: 'networkidle', | |
| 1460 }); | |
| 1461 const switchInput = page.locator('zen-switch input'); | |
| 1462 assert.equal(await switchInput.getAttribute('role'), 'switch'); | |
| 1463 assert.equal(await switchInput.getAttribute('aria-checked'), 'true'); | |
| 1464 await switchInput.uncheck(); | |
| 1465 assert.equal(await switchInput.getAttribute('aria-checked'), 'false'); | |
| 1466 | |
| 1467 await page.goto(`${baseUrl}/components/checkbox`, { | |
| 1468 waitUntil: 'networkidle', | |
| 1469 }); | |
| 1470 assert.equal( | |
| 1471 await page.locator('zen-checkbox input').evaluate( | |
| 1472 input => getComputedStyle(input).appearance, | |
| 1473 ), | |
| 1474 'none', | |
| 1475 ); | |
| 1476 await page.goto(`${baseUrl}/components/native-select`, { | |
| 1477 waitUntil: 'networkidle', | |
| 1478 }); | |
| 1479 assert.equal( | |
| 1480 await page.locator('zen-native-select select').evaluate( | |
| 1481 select => getComputedStyle(select).appearance, | |
| 1482 ), | |
| 1483 'none', | |
| 1484 ); | |
| 1485 assert.equal( | |
| 1486 await page.locator( | |
| 1487 'zen-native-select > zen-icon[data-zen-select-icon]', | |
| 1488 ).count(), | |
| 1489 1, | |
| 1490 ); | |
| 1491 await page.goto(`${baseUrl}/components/slider`, { | |
| 1492 waitUntil: 'networkidle', | |
| 1493 }); | |
| 1494 assert.equal( | |
| 1495 await page.locator('zen-slider input').evaluate( | |
| 1496 input => getComputedStyle(input).appearance, | |
| 1497 ), | |
| 1498 'none', | |
| 1499 ); | |
| 1500 | |
| 1501 await page.goto(`${baseUrl}/components/toggle-group`, { | |
| 1502 waitUntil: 'networkidle', | |
| 1503 }); | |
| 1504 await page.locator('zen-toggle-group button[value="right"]').click(); | |
| 1505 assert.equal( | |
| 1506 await page.locator('zen-toggle-group button[value="right"]') | |
| 1507 .getAttribute('aria-pressed'), | |
| 1508 'true', | |
| 1509 ); | |
| 1510 assert.equal( | |
| 1511 await page.locator('zen-toggle-group').getAttribute('value'), | |
| 1512 'right', | |
| 1513 ); | |
| 1514 | |
| 1515 await page.goto(`${baseUrl}/components/sidebar`, { | |
| 1516 waitUntil: 'networkidle', | |
| 1517 }); | |
| 1518 const sidebarTrigger = page.locator( | |
| 1519 'zen-sidebar [data-zen-sidebar-trigger]', | |
| 1520 ); | |
| 1521 const sidebarPanel = page.locator( | |
| 1522 'zen-sidebar [data-zen-sidebar-panel]', | |
| 1523 ); | |
| 1524 assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'true'); | |
| 1525 assert.equal(await sidebarPanel.getAttribute('hidden'), null); | |
| 1526 await sidebarTrigger.click(); | |
| 1527 assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'false'); | |
| 1528 assert.equal(await sidebarPanel.getAttribute('hidden'), ''); | |
| 1529 | |
| 1530 await page.goto(`${baseUrl}/components/notifications`, { | |
| 1531 waitUntil: 'networkidle', | |
| 1532 }); | |
| 1533 const notificationScope = page.locator('zen-notifications').first(); | |
| 1534 const notificationPosition = await notificationScope.locator( | |
| 1535 '[data-zen-notification-stack]', | |
| 1536 ).evaluate(stack => { | |
| 1537 const style = getComputedStyle(stack); | |
| 1538 return { | |
| 1539 bottom: style.bottom, | |
| 1540 position: style.position, | |
| 1541 right: style.right, | |
| 1542 }; | |
| 1543 }); | |
| 1544 assert.equal(notificationPosition.position, 'fixed'); | |
| 1545 assert.notEqual(notificationPosition.bottom, 'auto'); | |
| 1546 assert.notEqual(notificationPosition.right, 'auto'); | |
| 1547 | |
| 1548 const dedupe = await notificationScope.evaluate(scope => { | |
| 1549 const source = scope.querySelector('[data-notification-demo]'); | |
| 1550 const send = detail => source.dispatchEvent( | |
| 1551 new CustomEvent('zen-notify', { | |
| 1552 bubbles: true, | |
| 1553 composed: false, | |
| 1554 detail, | |
| 1555 }), | |
| 1556 ); | |
| 1557 send({ | |
| 1558 version: 1, | |
| 1559 id: 'dedupe', | |
| 1560 tone: 'info', | |
| 1561 message: 'First announcement', | |
| 1562 announcement: 'polite', | |
| 1563 persistent: true, | |
| 1564 }); | |
| 1565 send({ | |
| 1566 version: 1, | |
| 1567 id: 'dedupe', | |
| 1568 tone: 'success', | |
| 1569 message: 'Updated without duplication', | |
| 1570 announcement: 'polite', | |
| 1571 persistent: true, | |
| 1572 }); | |
| 1573 send({ version: 99, id: 'invalid' }); | |
| 1574 return { | |
| 1575 size: scope.size, | |
| 1576 visible: scope.visibleCount, | |
| 1577 }; | |
| 1578 }); | |
| 1579 assert.deepEqual(dedupe, { size: 1, visible: 1 }); | |
| 1580 assert.equal( | |
| 1581 await notificationScope.locator('article').count(), | |
| 1582 1, | |
| 1583 ); | |
| 1584 assert.match( | |
| 1585 await notificationScope.locator('article').textContent(), | |
| 1586 /Updated without duplication/, | |
| 1587 ); | |
| 1588 await page.waitForFunction(() => | |
| 1589 document.querySelector( | |
| 1590 'zen-notifications [data-zen-live="polite"]', | |
| 1591 )?.textContent === 'First announcement' | |
| 1592 ); | |
| 1593 | |
| 1594 const bounded = await notificationScope.evaluate(scope => { | |
| 1595 let accepted = 0; | |
| 1596 for (let index = 0; index < 25; index++) { | |
| 1597 if (scope.notify({ | |
| 1598 version: 1, | |
| 1599 id: `bounded-${index}`, | |
| 1600 tone: 'info', | |
| 1601 message: `Bounded ${index}`, | |
| 1602 announcement: 'none', | |
| 1603 durationMs: 120000, | |
| 1604 })) accepted++; | |
| 1605 } | |
| 1606 return { | |
| 1607 accepted, | |
| 1608 size: scope.size, | |
| 1609 visible: scope.visibleCount, | |
| 1610 }; | |
| 1611 }); | |
| 1612 assert.deepEqual(bounded, { accepted: 25, size: 23, visible: 3 }); | |
| 1613 assert.equal( | |
| 1614 await notificationScope.locator('article').count(), | |
| 1615 3, | |
| 1616 ); | |
| 1617 await page.waitForFunction(() => | |
| 1618 [...document.querySelectorAll( | |
| 1619 'zen-notifications article', | |
| 1620 )].some(article => article.dataset.depth === '2') | |
| 1621 ); | |
| 1622 const collapsedStack = await notificationScope.locator( | |
| 1623 '[data-zen-notification-stack]', | |
| 1624 ).evaluate(stack => { | |
| 1625 const articles = [...stack.querySelectorAll('article')]; | |
| 1626 return { | |
| 1627 depths: articles.map(article => article.dataset.depth), | |
| 1628 height: stack.getBoundingClientRect().height, | |
| 1629 positions: articles.map(article => | |
| 1630 getComputedStyle(article).position | |
| 1631 ), | |
| 1632 transforms: articles.map(article => | |
| 1633 getComputedStyle(article).transform | |
| 1634 ), | |
| 1635 }; | |
| 1636 }); | |
| 1637 assert.deepEqual(collapsedStack.depths, ['2', '1', '0']); | |
| 1638 assert.deepEqual( | |
| 1639 collapsedStack.positions, | |
| 1640 ['absolute', 'absolute', 'absolute'], | |
| 1641 ); | |
| 1642 await notificationScope.locator('article').last().hover(); | |
| 1643 await page.waitForTimeout(300); | |
| 1644 const expandedStack = await notificationScope.locator( | |
| 1645 '[data-zen-notification-stack]', | |
| 1646 ).evaluate(stack => ({ | |
| 1647 height: stack.getBoundingClientRect().height, | |
| 1648 rects: [...stack.querySelectorAll('article')].map(article => { | |
| 1649 const bounds = article.getBoundingClientRect(); | |
| 1650 return { | |
| 1651 bottom: bounds.bottom, | |
| 1652 left: bounds.left, | |
| 1653 right: bounds.right, | |
| 1654 top: bounds.top, | |
| 1655 }; | |
| 1656 }), | |
| 1657 tops: [...stack.querySelectorAll('article')].map( | |
| 1658 article => Math.round(article.getBoundingClientRect().top), | |
| 1659 ), | |
| 1660 transforms: [...stack.querySelectorAll('article')].map( | |
| 1661 article => getComputedStyle(article).transform, | |
| 1662 ), | |
| 1663 })); | |
| 1664 assert.ok(expandedStack.height > collapsedStack.height); | |
| 1665 assert.equal(new Set(expandedStack.tops).size, 3); | |
| 1666 const expandedGaps = [ | |
| 1667 expandedStack.rects[1].top - expandedStack.rects[0].bottom, | |
| 1668 expandedStack.rects[2].top - expandedStack.rects[1].bottom, | |
| 1669 ]; | |
| 1670 for (const gap of expandedGaps) { | |
| 1671 assert.ok(gap >= 10 && gap <= 14, JSON.stringify(expandedGaps)); | |
| 1672 } | |
| 1673 assert.notDeepEqual( | |
| 1674 expandedStack.transforms, | |
| 1675 collapsedStack.transforms, | |
| 1676 ); | |
| 1677 const gapX = | |
| 1678 (expandedStack.rects[0].left + expandedStack.rects[0].right) / 2; | |
| 1679 const gapY = | |
| 1680 (expandedStack.rects[0].bottom + expandedStack.rects[1].top) / 2; | |
| 1681 await page.mouse.move(gapX, gapY); | |
| 1682 await page.waitForTimeout(100); | |
| 1683 const gapState = await notificationScope.locator( | |
| 1684 '[data-zen-notification-stack]', | |
| 1685 ).evaluate(stack => ({ | |
| 1686 height: stack.getBoundingClientRect().height, | |
| 1687 hovered: stack.matches(':hover'), | |
| 1688 })); | |
| 1689 assert.equal(gapState.hovered, true); | |
| 1690 assert.ok(gapState.height >= expandedStack.height - 1); | |
| 1691 await page.mouse.move(0, 0); | |
| 1692 | |
| 1693 const actionScope = await page.evaluate(() => { | |
| 1694 const scope = document.createElement('zen-notifications'); | |
| 1695 scope.id = 'action-scope'; | |
| 1696 const source = document.createElement('button'); | |
| 1697 scope.append(source); | |
| 1698 document.body.append(scope); | |
| 1699 window.__notificationAction = null; | |
| 1700 scope.addEventListener('zen-notification-action', event => { | |
| 1701 window.__notificationAction = event.detail; | |
| 1702 }); | |
| 1703 source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1704 bubbles: true, | |
| 1705 composed: false, | |
| 1706 detail: { | |
| 1707 version: 1, | |
| 1708 id: 'action', | |
| 1709 tone: 'error', | |
| 1710 message: 'Action required', | |
| 1711 announcement: 'assertive', | |
| 1712 persistent: true, | |
| 1713 action: { | |
| 1714 token: 'opaque-secret-token', | |
| 1715 label: 'Retry', | |
| 1716 }, | |
| 1717 }, | |
| 1718 })); | |
| 1719 return scope.id; | |
| 1720 }); | |
| 1721 assert.equal( | |
| 1722 await page.locator(`#${actionScope}`).evaluate( | |
| 1723 scope => scope.records, | |
| 1724 ), | |
| 1725 undefined, | |
| 1726 ); | |
| 1727 const actionArticle = page.locator( | |
| 1728 `#${actionScope} [data-zen-notification-id="action"]`, | |
| 1729 ); | |
| 1730 assert.doesNotMatch( | |
| 1731 await actionArticle.evaluate(article => article.outerHTML), | |
| 1732 /opaque-secret-token/, | |
| 1733 ); | |
| 1734 await actionArticle.locator('.zen-notification-action').focus(); | |
| 1735 await page.locator(`#${actionScope}`).evaluate(scope => { | |
| 1736 const source = scope.querySelector('button'); | |
| 1737 source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1738 bubbles: true, | |
| 1739 composed: false, | |
| 1740 detail: { | |
| 1741 version: 1, | |
| 1742 id: 'action-neighbor', | |
| 1743 tone: 'info', | |
| 1744 message: 'Neighbor', | |
| 1745 announcement: 'none', | |
| 1746 persistent: true, | |
| 1747 }, | |
| 1748 })); | |
| 1749 }); | |
| 1750 assert.equal( | |
| 1751 await actionArticle.locator('.zen-notification-action').evaluate( | |
| 1752 action => document.activeElement === action, | |
| 1753 ), | |
| 1754 true, | |
| 1755 ); | |
| 1756 await actionArticle.locator('.zen-notification-action').click(); | |
| 1757 assert.deepEqual( | |
| 1758 await page.evaluate(() => window.__notificationAction), | |
| 1759 { | |
| 1760 version: 1, | |
| 1761 id: 'action', | |
| 1762 token: 'opaque-secret-token', | |
| 1763 }, | |
| 1764 ); | |
| 1765 await page.locator(`#${actionScope}`).evaluate(scope => { | |
| 1766 const source = scope.querySelector('button'); | |
| 1767 source.dispatchEvent(new CustomEvent( | |
| 1768 'zen-dismiss-notification', | |
| 1769 { | |
| 1770 bubbles: true, | |
| 1771 composed: false, | |
| 1772 detail: { version: 1, id: 'action' }, | |
| 1773 }, | |
| 1774 )); | |
| 1775 }); | |
| 1776 await actionArticle.waitFor({ state: 'detached' }); | |
| 1777 | |
| 1778 await page.evaluate(() => { | |
| 1779 const scope = document.createElement('zen-notifications'); | |
| 1780 scope.id = 'announcement-scope'; | |
| 1781 const source = document.createElement('button'); | |
| 1782 scope.append(source); | |
| 1783 document.body.append(scope); | |
| 1784 window.__announcements = []; | |
| 1785 const region = scope.querySelector('[data-zen-live="polite"]'); | |
| 1786 new MutationObserver(() => { | |
| 1787 if (region.textContent) { | |
| 1788 window.__announcements.push(region.textContent); | |
| 1789 } | |
| 1790 }).observe(region, { childList: true }); | |
| 1791 for (let index = 0; index < 3; index++) { | |
| 1792 source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1793 bubbles: true, | |
| 1794 composed: false, | |
| 1795 detail: { | |
| 1796 version: 1, | |
| 1797 id: `announcement-${index}`, | |
| 1798 tone: 'info', | |
| 1799 message: `Announcement ${index}`, | |
| 1800 announcement: 'polite', | |
| 1801 persistent: true, | |
| 1802 }, | |
| 1803 })); | |
| 1804 } | |
| 1805 }); | |
| 1806 await page.waitForFunction(() => | |
| 1807 window.__announcements?.length === 3 | |
| 1808 ); | |
| 1809 assert.deepEqual( | |
| 1810 await page.evaluate(() => window.__announcements), | |
| 1811 ['Announcement 0', 'Announcement 1', 'Announcement 2'], | |
| 1812 ); | |
| 1813 | |
| 1814 const composedRejected = await page.evaluate(() => { | |
| 1815 const scope = document.createElement('zen-notifications'); | |
| 1816 const source = document.createElement('button'); | |
| 1817 scope.append(source); | |
| 1818 document.body.append(scope); | |
| 1819 source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1820 bubbles: true, | |
| 1821 composed: true, | |
| 1822 detail: { | |
| 1823 version: 1, | |
| 1824 id: 'composed', | |
| 1825 tone: 'info', | |
| 1826 message: 'Must be rejected', | |
| 1827 announcement: 'none', | |
| 1828 persistent: true, | |
| 1829 }, | |
| 1830 })); | |
| 1831 return scope.size; | |
| 1832 }); | |
| 1833 assert.equal(composedRejected, 0); | |
| 1834 | |
| 1835 await page.evaluate(() => { | |
| 1836 const scope = document.createElement('zen-notifications'); | |
| 1837 scope.id = 'focus-timer-scope'; | |
| 1838 const source = document.createElement('button'); | |
| 1839 scope.append(source); | |
| 1840 document.body.append(scope); | |
| 1841 source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1842 bubbles: true, | |
| 1843 composed: false, | |
| 1844 detail: { | |
| 1845 version: 1, | |
| 1846 id: 'focus-timer', | |
| 1847 tone: 'warning', | |
| 1848 message: 'Focus timer', | |
| 1849 announcement: 'none', | |
| 1850 durationMs: 300, | |
| 1851 action: { token: 'focus-token', label: 'Keep focused' }, | |
| 1852 }, | |
| 1853 })); | |
| 1854 }); | |
| 1855 const focusTimer = page.locator( | |
| 1856 '#focus-timer-scope [data-zen-notification-id="focus-timer"]', | |
| 1857 ); | |
| 1858 await focusTimer.locator('.zen-notification-action').focus(); | |
| 1859 await page.locator('#focus-timer-scope').evaluate(scope => { | |
| 1860 const source = scope.querySelector(':scope > button'); | |
| 1861 source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1862 bubbles: true, | |
| 1863 composed: false, | |
| 1864 detail: { | |
| 1865 version: 1, | |
| 1866 id: 'focus-neighbor', | |
| 1867 tone: 'info', | |
| 1868 message: 'Focus neighbor', | |
| 1869 announcement: 'none', | |
| 1870 persistent: true, | |
| 1871 }, | |
| 1872 })); | |
| 1873 }); | |
| 1874 await page.waitForTimeout(500); | |
| 1875 assert.equal(await focusTimer.count(), 1); | |
| 1876 assert.equal( | |
| 1877 await focusTimer.locator('.zen-notification-action').evaluate( | |
| 1878 action => document.activeElement === action, | |
| 1879 ), | |
| 1880 true, | |
| 1881 ); | |
| 1882 await page.mouse.move(0, 0); | |
| 1883 await page.locator('#catalogMain').focus(); | |
| 1884 await page.waitForFunction(() => | |
| 1885 !document.querySelector( | |
| 1886 '#focus-timer-scope [data-zen-notification-id="focus-timer"]', | |
| 1887 ) | |
| 1888 ); | |
| 1889 | |
| 1890 await page.evaluate(() => { | |
| 1891 const scope = document.createElement('zen-notifications'); | |
| 1892 scope.id = 'timer-scope'; | |
| 1893 const source = document.createElement('button'); | |
| 1894 scope.append(source); | |
| 1895 document.body.append(scope); | |
| 1896 source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1897 bubbles: true, | |
| 1898 composed: false, | |
| 1899 detail: { | |
| 1900 version: 1, | |
| 1901 id: 'timer', | |
| 1902 tone: 'info', | |
| 1903 message: 'Paused timer', | |
| 1904 announcement: 'none', | |
| 1905 durationMs: 300, | |
| 1906 }, | |
| 1907 })); | |
| 1908 }); | |
| 1909 const timerArticle = page.locator('#timer-scope article'); | |
| 1910 await timerArticle.hover(); | |
| 1911 await page.waitForTimeout(500); | |
| 1912 assert.equal(await timerArticle.count(), 1); | |
| 1913 await page.mouse.move(0, 0); | |
| 1914 await page.waitForFunction(() => | |
| 1915 !document.querySelector('#timer-scope article') | |
| 1916 ); | |
| 1917 | |
| 1918 await page.evaluate(() => { | |
| 1919 const scope = document.createElement('zen-notifications'); | |
| 1920 scope.id = 'hidden-timer-scope'; | |
| 1921 const source = document.createElement('button'); | |
| 1922 scope.append(source); | |
| 1923 document.body.append(scope); | |
| 1924 source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1925 bubbles: true, | |
| 1926 composed: false, | |
| 1927 detail: { | |
| 1928 version: 1, | |
| 1929 id: 'hidden-timer', | |
| 1930 tone: 'info', | |
| 1931 message: 'Hidden timer', | |
| 1932 announcement: 'none', | |
| 1933 durationMs: 300, | |
| 1934 }, | |
| 1935 })); | |
| 1936 Object.defineProperty(document, 'hidden', { | |
| 1937 configurable: true, | |
| 1938 value: true, | |
| 1939 }); | |
| 1940 document.dispatchEvent(new Event('visibilitychange')); | |
| 1941 }); | |
| 1942 await page.waitForTimeout(500); | |
| 1943 assert.equal( | |
| 1944 await page.locator('#hidden-timer-scope article').count(), | |
| 1945 1, | |
| 1946 ); | |
| 1947 await page.evaluate(() => { | |
| 1948 Object.defineProperty(document, 'hidden', { | |
| 1949 configurable: true, | |
| 1950 value: false, | |
| 1951 }); | |
| 1952 document.dispatchEvent(new Event('visibilitychange')); | |
| 1953 }); | |
| 1954 await page.waitForFunction(() => | |
| 1955 !document.querySelector('#hidden-timer-scope article') | |
| 1956 ); | |
| 1957 | |
| 1958 const isolated = await page.evaluate(() => { | |
| 1959 const makeScope = id => { | |
| 1960 const scope = document.createElement('zen-notifications'); | |
| 1961 scope.id = id; | |
| 1962 const source = document.createElement('button'); | |
| 1963 scope.append(source); | |
| 1964 document.body.append(scope); | |
| 1965 return { scope, source }; | |
| 1966 }; | |
| 1967 const first = makeScope('scope-one'); | |
| 1968 const second = makeScope('scope-two'); | |
| 1969 first.source.dispatchEvent(new CustomEvent('zen-notify', { | |
| 1970 bubbles: true, | |
| 1971 composed: false, | |
| 1972 detail: { | |
| 1973 version: 1, | |
| 1974 id: 'isolated', | |
| 1975 tone: 'success', | |
| 1976 message: 'Only first scope', | |
| 1977 announcement: 'none', | |
| 1978 persistent: true, | |
| 1979 }, | |
| 1980 })); | |
| 1981 return [first.scope.size, second.scope.size]; | |
| 1982 }); | |
| 1983 assert.deepEqual(isolated, [1, 0]); | |
| 1984 | |
| 1985 const lightCanvas = await page.evaluate(() => | |
| 1986 getComputedStyle(document.documentElement) | |
| 1987 .getPropertyValue('--zenbu-sys-color-surface-page') | |
| 1988 .trim() | |
| 1989 ); | |
| 1990 await page.locator('#themeToggle').click(); | |
| 1991 const darkCanvas = await page.evaluate(() => | |
| 1992 getComputedStyle(document.documentElement) | |
| 1993 .getPropertyValue('--zenbu-sys-color-surface-page') | |
| 1994 .trim() | |
| 1995 ); | |
| 1996 assert.notEqual(lightCanvas, darkCanvas); | |
| 1997 assert.equal( | |
| 1998 await page.evaluate(() => localStorage.getItem('zen-theme')), | |
| 1999 'dark', | |
| 2000 ); | |
| 2001 | |
| 2002 assert.deepEqual(errors, []); | |
| 2003 await context.close(); | |
| 2004 | |
| 2005 const darkContext = await browser.newContext({ colorScheme: 'dark' }); | |
| 2006 const darkPage = await darkContext.newPage(); | |
| 2007 await darkPage.goto(baseUrl, { waitUntil: 'networkidle' }); | |
| 2008 assert.equal( | |
| 2009 await darkPage.locator('#themeToggle').getAttribute('aria-pressed'), | |
| 2010 'true', | |
| 2011 ); | |
| 2012 const systemDarkCanvas = await darkPage.evaluate(() => | |
| 2013 getComputedStyle(document.documentElement) | |
| 2014 .getPropertyValue('--zenbu-sys-color-surface-page') | |
| 2015 .trim() | |
| 2016 ); | |
| 2017 await darkPage.locator('#themeToggle').click(); | |
| 2018 const explicitLightCanvas = await darkPage.evaluate(() => | |
| 2019 getComputedStyle(document.documentElement) | |
| 2020 .getPropertyValue('--zenbu-sys-color-surface-page') | |
| 2021 .trim() | |
| 2022 ); | |
| 2023 assert.notEqual(systemDarkCanvas, explicitLightCanvas); | |
| 2024 await darkContext.close(); | |
| 2025 | |
| 2026 const mobileContext = await browser.newContext({ | |
| 2027 colorScheme: 'light', | |
| 2028 viewport: { width: 390, height: 844 }, | |
| 2029 }); | |
| 2030 const mobilePage = await mobileContext.newPage(); | |
| 2031 const mobileErrors = []; | |
| 2032 mobilePage.on('pageerror', error => { | |
| 2033 mobileErrors.push(error.stack || error.message); | |
| 2034 }); | |
| 2035 mobilePage.on('console', message => { | |
| 2036 if (message.type() === 'error') mobileErrors.push(message.text()); | |
| 2037 }); | |
| 2038 await mobilePage.goto(baseUrl, { waitUntil: 'networkidle' }); | |
| 2039 await mobilePage.waitForFunction(() => | |
| 2040 customElements.get('zen-date-picker') && | |
| 2041 document.querySelectorAll( | |
| 2042 '.catalog-nav[aria-label="Components"] a', | |
| 2043 ).length === 70 | |
| 2044 ); | |
| 2045 const mobileFailures = await mobilePage.evaluate(() => { | |
| 2046 const routes = [ | |
| 2047 '/', | |
| 2048 '/tokens', | |
| 2049 '/icons', | |
| 2050 ...[...document.querySelectorAll( | |
| 2051 '.catalog-nav[aria-label="Components"] a', | |
| 2052 )].map(link => new URL(link.href).pathname), | |
| 2053 ]; | |
| 2054 const failures = []; | |
| 2055 for (const route of routes) { | |
| 2056 history.replaceState({}, '', route); | |
| 2057 window.dispatchEvent(new PopStateEvent('popstate')); | |
| 2058 const page = document.querySelector( | |
| 2059 '[data-catalog-page]:not([hidden])', | |
| 2060 ); | |
| 2061 if (!page || | |
| 2062 document.documentElement.scrollWidth > innerWidth + 1 || | |
| 2063 page.getBoundingClientRect().right > innerWidth + 1) { | |
| 2064 failures.push(route); | |
| 2065 } | |
| 2066 } | |
| 2067 return failures; | |
| 2068 }); | |
| 2069 assert.deepEqual(mobileFailures, []); | |
| 2070 await mobilePage.goto(`${baseUrl}/components/date-picker`, { | |
| 2071 waitUntil: 'networkidle', | |
| 2072 }); | |
| 2073 await mobilePage.locator( | |
| 2074 'zen-date-picker [data-zen-date-trigger]', | |
| 2075 ).click(); | |
| 2076 const mobileCalendarBounds = await mobilePage.locator( | |
| 2077 'zen-date-picker [data-zen-calendar-panel]', | |
| 2078 ).evaluate(panel => { | |
| 2079 const bounds = panel.getBoundingClientRect(); | |
| 2080 return { | |
| 2081 bottom: bounds.bottom, | |
| 2082 left: bounds.left, | |
| 2083 right: bounds.right, | |
| 2084 top: bounds.top, | |
| 2085 }; | |
| 2086 }); | |
| 2087 assert.ok(mobileCalendarBounds.left >= 0); | |
| 2088 assert.ok(mobileCalendarBounds.right <= 390); | |
| 2089 assert.ok(mobileCalendarBounds.top >= 0); | |
| 2090 assert.ok(mobileCalendarBounds.bottom <= 844); | |
| 2091 assert.deepEqual(mobileErrors, []); | |
| 2092 await mobileContext.close(); | |
| 2093 } finally { | |
| 2094 if (browser) await browser.close(); | |
| 2095 await stopProcess(server); | |
| 2096 } | |
| 2097 })().catch(error => { | |
| 2098 console.error(error.stack || error); | |
| 2099 process.exitCode = 1; | |
| 2100 }); |