recipes/ui/main.ts
One file of recipes/ui, at commit 9a5bb24.
// The gallery, built and driven: what a page made of `@aweftjs/ui` does in a real browser.
//
// The job is the ordinary one: build the page with the bundler plugin, open it, and use it. What
// makes it a recipe rather than a demo is that every step asserts, including the ones only a real
// browser can answer: that the theme's CSS reached the head and is applied, that a real click and
// a real keystroke reach the handlers, that focus moves, that a popup is measured against its
// anchor and placed, and that it asks for the top layer with `popover` rather than a z-index.
//
// It drives three pages. The gallery is every system the package ships. The preview is the look
// itself, light and dark side by side, and it is where the look is judged. The catalogue is
// every component the package exports, one routed page each, in every state and in both modes,
// built from one example file each (designs 197 and 226). The assertions on the last two are about
// the contract rather than about the systems, and axe-core runs over every page of both.
//
// Run: node recipes/ui/main.ts
// Serve it instead, to click around: npx vite recipes/ui
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFileSync, readdirSync } from 'node:fs';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import { chromium } from 'playwright';
import { audit, walk as tabWalk } from '@aweftjs/testing/browser';
import { context, dark, light } from '@aweftjs/ui';
const here = fileURLToPath(new URL('.', import.meta.url));
const dist = join(here, 'dist');
const TYPES: Record<string, string> = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json',
};
/** Serve the built page, and nothing outside it. */
const serve = async (): Promise<{ url: string; close(): Promise<void> }> => {
const server = createServer((request, response) => {
const path = (request.url ?? '/').split('?')[0]!;
const file = join(dist, normalize(path === '/' ? '/index.html' : path));
if (!file.startsWith(dist)) {
response.writeHead(403).end();
return;
}
try {
const body = readFileSync(file);
response.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' });
response.end(body);
} catch {
response.writeHead(404).end();
}
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as { port: number }).port;
return {
url: `http://127.0.0.1:${port}/`,
close: () => new Promise<void>((resolve) => { server.close(() => resolve()); }),
};
};
console.log('building the gallery through aweft()');
await build({ configFile: join(here, 'vite.config.ts'), logLevel: 'warn' });
const site = await serve();
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1000, height: 800 } });
const problems: string[] = [];
page.on('pageerror', (error) => problems.push(String(error)));
try {
await page.goto(site.url);
await page.waitForSelector('#page');
// --- the stylesheet is real, and it applies ------------------------------------------------
// A render's sheet is the element the page loaded with plus one per class compiled after the
// mount, all marked (design 257), so the whole sheet is every one of them.
const sheet = await page.evaluate(() => {
const text = Array.from(document.head.querySelectorAll('style[data-aweft]'))
.map((style) => style.textContent ?? '').join('\n');
return { text, layers: text.includes('@layer aweft') };
});
assert.ok(sheet.layers, 'every rule ui emits sits inside @layer aweft');
assert.ok(!sheet.text.includes('!important'), 'ui ships zero !important');
const applied = await page.evaluate(() => {
const style = getComputedStyle(document.querySelector('#counter')!);
return { background: style.backgroundColor, radius: style.borderRadius };
});
assert.equal(applied.background, 'rgb(27, 110, 243)', 'the theme colour reached the element');
assert.equal(applied.radius, '6px');
// The colour function ran at compile time, over a real colour, and picked white on this blue.
const ink = await page.evaluate(() => getComputedStyle(document.querySelector('#counter')!).color);
assert.equal(ink, 'rgb(255, 255, 255)', '$contrast_text picked the readable ink');
// A function this page defined, not one the package shipped.
const sized = await page.evaluate(() => getComputedStyle(document.querySelector('#sized')!).fontSize);
assert.equal(sized, '20px', 'the page\'s own $em ran');
// A nested theme generated its own class rather than editing the outer one.
const tiles = await page.evaluate(() => [
getComputedStyle(document.querySelector('#outer-tile')!).backgroundColor,
getComputedStyle(document.querySelector('#inner-tile')!).backgroundColor,
]);
assert.equal(tiles[0], 'rgb(255, 255, 255)');
assert.equal(tiles[1], 'rgb(253, 243, 216)', 'the nested theme applies to its subtree only');
// --- real events -----------------------------------------------------------------------------
await page.click('#counter');
await page.click('#counter');
assert.match(await page.textContent('#counter') ?? '', /clicked 2 times/);
// Hovering drives the cell, which drives the theme, which drives the class.
await page.hover('#counter');
const hovered = await page.evaluate(() => getComputedStyle(document.querySelector('#counter')!).backgroundColor);
assert.notEqual(hovered, 'rgb(27, 110, 243)', 'isHovered moved the theme');
await page.mouse.move(0, 0);
await page.focus('#counter');
assert.equal(await page.textContent('#focus-state'), 'focused', 'isFocused followed real focus');
await page.evaluate(() => { document.querySelector('#counter')!.blur(); });
assert.equal(await page.textContent('#focus-state'), 'not focused');
// A real keyboard, not a hand-made event object.
await page.click('#field');
await page.keyboard.type('hello');
assert.equal(await page.textContent('#typed'), 'hello');
// Tab moves focus in DOM order, which is what a page that emits no positive tabindex gets.
const positive = await page.evaluate(() =>
Array.from(document.querySelectorAll('[tabindex]'))
.filter((n) => Number(n.getAttribute('tabindex')) > 0).length);
assert.equal(positive, 0, 'ui emits no positive tabindex');
// --- control flow ------------------------------------------------------------------------------
assert.equal(await page.locator('#then').count(), 1);
await page.click('#toggle');
assert.equal(await page.locator('#then').count(), 0);
assert.equal(await page.locator('#else').count(), 1);
assert.equal(await page.locator('#case-one').count(), 1);
await page.click('#switch');
assert.equal(await page.locator('#case-two').count(), 1);
assert.equal(await page.locator('#rows li').count(), 2);
await page.click('#add-row');
assert.equal(await page.locator('#rows li').count(), 3);
// --- the popup -----------------------------------------------------------------------------
assert.equal(await page.locator('#menu').isVisible(), false, 'a closed popup is not on the screen');
// With the anchor in the middle of the screen there is room under it, so the first mode the
// solver is asked for is the one it can honour.
await page.evaluate(() => { document.querySelector('#anchor')!.scrollIntoView({ block: 'center' }); });
await page.click('#anchor');
// It is laid out out of sight for one frame so it has a size to be placed by, so wait for the
// frame that placed it rather than for the one that showed it.
await page.waitForFunction(() => {
const popup = document.querySelector('#menu')?.parentElement;
if (popup === null || popup === undefined) return false;
const style = getComputedStyle(popup);
return style.position === 'fixed' && style.visibility === 'visible';
});
const placed = await page.evaluate(() => {
const anchor = document.querySelector('#anchor')!.getBoundingClientRect();
const popup = document.querySelector('#menu')!.parentElement!;
const box = popup.getBoundingClientRect();
return {
anchorBottom: anchor.bottom, anchorLeft: anchor.left,
popupTop: box.top, popupLeft: box.left,
popover: popup.getAttribute('popover'),
zIndex: getComputedStyle(popup).zIndex,
};
});
assert.ok(Math.abs(placed.popupTop - placed.anchorBottom) < 2, 'the popup was measured and put under its anchor');
assert.ok(Math.abs(placed.popupLeft - placed.anchorLeft) < 2, 'and lined up with its left edge');
assert.equal(placed.popover, 'manual', 'it asked for the top layer with the popover attribute');
assert.equal(placed.zIndex, 'auto', 'and there is no z-index anywhere');
// The top layer is what puts it above a stacking context it is not inside.
const above = await page.evaluate(() => {
const popup = document.querySelector('#menu')!.parentElement!;
const box = popup.getBoundingClientRect();
const found = document.elementFromPoint(box.left + 4, box.top + 4);
return found !== null && popup.contains(found);
});
assert.ok(above, 'the popup is what the pointer finds where the popup is');
// Scrolling closes it, on the reading that a popup whose anchor has moved has had its moment.
await page.evaluate(() => window.scrollTo(0, 200));
await page.waitForFunction(() => {
const popup = document.querySelector('#menu')?.parentElement;
return popup !== null && popup !== undefined && getComputedStyle(popup).display === 'none';
});
// --- text modifiers --------------------------------------------------------------------------
assert.equal(await page.evaluate(() => document.querySelectorAll('#note b').length), 0,
'nothing in the note matches yet');
await page.click('#note-field');
await page.keyboard.press('End');
await page.keyboard.type(', TODO ask @ada');
await page.waitForFunction(() => document.querySelectorAll('#note b').length === 1);
assert.equal(await page.evaluate(() => document.querySelectorAll('#note i').length), 1,
'the second modifier found the mention in the same pass');
assert.match(await page.textContent('#note') ?? '', /nothing to do yet, TODO ask @ada/,
'and the gaps between the matches are the text that was typed');
// --- loading -------------------------------------------------------------------------------
await page.waitForSelector('#arrived');
await page.waitForSelector('#failed');
assert.match(await page.textContent('#failed') ?? '', /the loader said no/,
'a rejected loader shows the failure rather than the spinner forever');
assert.equal(await page.locator('#spinner').count(), 0, 'no spinner was left behind');
// --- the preview page: the look, in both modes -----------------------------------------------
// The roles as the theme holds them. What is checked below is that the browser is showing the
// value the theme says, in the mode the pane asked for.
const roles = context().theme;
const roleOf = (values: typeof light, name: string): string => {
const held = roles.variable(values, [], name);
assert.ok(held !== null, `the theme defines $${name}`);
return held;
};
// A role's value as it is written in the stylesheet, escaped for a regular expression.
const rgbHex = (hex: string): string => hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const rgb = (hex: string): string => {
const value = parseInt(hex.slice(1), 16);
return `rgb(${String((value >> 16) & 255)}, ${String((value >> 8) & 255)}, ${String(value & 255)})`;
};
await page.goto(site.url + 'preview.html');
await page.waitForSelector('#preview');
const previewSheet = await page.evaluate(() => Array.from(document.head.querySelectorAll('style[data-aweft]'))
.map((style) => style.textContent ?? '').join('\n'));
assert.ok(previewSheet.includes('@layer aweft'), 'the preview page carries the theme sheet');
assert.ok(!previewSheet.includes('!important'), 'and still no !important');
const paint = async (selector: string): Promise<Record<string, string | undefined>> =>
page.evaluate((query: string) => {
const style = getComputedStyle(document.querySelector(query)!);
return {
background: style.backgroundColor,
backgroundImage: style.backgroundImage,
color: style.color,
borderColor: style.borderTopColor,
outlineStyle: style.outlineStyle,
boxShadow: style.boxShadow,
transitionDuration: style.transitionDuration,
transitionTimingFunction: style.transitionTimingFunction,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
minHeight: style.minHeight,
};
}, selector);
const lightButton = await paint('#button-light');
const darkButton = await paint('#button-dark');
assert.equal(lightButton.background, rgb(roleOf(light, 'accent')), 'the light button is the light $accent');
assert.equal(lightButton.color, rgb(roleOf(light, 'accentForeground')), 'on the light $accentForeground');
assert.equal(darkButton.background, rgb(roleOf(dark, 'accent')), 'the dark button is the dark $accent');
assert.equal(darkButton.color, rgb(roleOf(dark, 'accentForeground')), 'on the dark $accentForeground');
assert.notEqual(lightButton.background, darkButton.background,
'the two modes nested on one page resolve their own roles');
const lightCard = await paint('#card-light');
const darkCard = await paint('#card-dark');
assert.equal(lightCard.background, rgb(roleOf(light, 'surface')));
assert.equal(darkCard.background, rgb(roleOf(dark, 'surface')));
assert.notEqual(lightCard.borderColor, darkCard.borderColor, 'and so does the border');
// One control height, and type in rem, both come out of the contract.
assert.equal(lightButton.minHeight, '36px', '$control is 36px (design 192)');
const body = await paint('#body-light');
assert.equal(body.fontSize, '16px', '$textMd is one rem');
assert.equal(body.lineHeight, '24px', 'and it has its line height');
// The type section, which is `Typography` rather than hand-written markup. The sizes are the
// scale in rem read back in the pixels a 16px root makes of them (design 182).
const type = await page.evaluate(() => {
const style = (id: string): Record<string, string> => getComputedStyle(document.querySelector(id)!);
return {
h1Size: style('#type-h1-light')['fontSize'],
h1Tag: document.querySelector('#type-h1-light')!.tagName.toLowerCase(),
h3Tag: document.querySelector('#type-h3-light')!.tagName.toLowerCase(),
boldWeight: style('#type-h2-bold-light')['fontWeight'],
p1Size: style('#type-p1-light')['fontSize'],
p2Size: style('#type-p2-light')['fontSize'],
darkH1: style('#type-h1-dark')['color'],
lightH1: style('#type-h1-light')['color'],
inlineTag: document.querySelector('#type-inline-light')!.tagName,
inlineParent: document.querySelector('#type-inline-light')!.parentElement!.tagName,
};
});
assert.equal(type.h1Size, '36px', '$text4xl is 2.25rem of the 16px root');
assert.equal(type.p1Size, '16px', 'a paragraph is body size');
assert.equal(type.p2Size, '14px', 'and the smaller one is $textSm');
assert.equal(type.boldWeight, '600', 'text_bold after text_h2 is what the browser computes');
assert.equal(type.h3Tag, 'h3', 'the first segment picked the element');
assert.equal(type.h1Tag, 'p', 'and element put the h1 look on the level the outline wanted');
assert.notEqual(type.lightH1, type.darkH1, 'the type section resolves the mode of its pane');
assert.equal(type.inlineTag, 'SPAN', 'a run inside a line of text is a span');
assert.equal(type.inlineParent, 'P',
'and it is still inside the paragraph: a <p> here would have closed the outer one early');
// Hover is a tint of the element's own foreground, laid over whatever background it has.
assert.equal(lightButton.backgroundImage, 'none', 'no tint before the pointer arrives');
await page.hover('#button-light');
const hoveredButton = await paint('#button-light');
assert.notEqual(hoveredButton.backgroundImage, 'none', 'a real hover lays the tint on');
assert.equal(hoveredButton.background, lightButton.background, 'and leaves the role underneath it');
await page.mouse.move(0, 0);
// A real Tab shows the ring. `:focus-visible` is what the rule is written against, so a
// keyboard has to be what moves the focus.
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => document.activeElement?.getAttribute('id') ?? '');
assert.equal(focused, 'button-light', 'the first Tab reaches the first control');
const ringed = await paint('#button-light');
assert.equal(ringed.outlineStyle, 'none', 'the ring is not an outline any more (design 192)');
// A halo of `$ring` at half strength, `$ringWidth` wide. Chromium writes a mixed colour in its
// own notation, so what is checked is the spread and that the colour is the role at half alpha.
assert.match(ringed.boxShadow ?? '', /0px 0px 0px 3px/, 'a $ringWidth halo, drawn as a box shadow');
assert.ok((ringed.boxShadow ?? '').includes('0.5'), `the halo is $ring at half strength: ${String(ringed.boxShadow)}`);
// The border moves to `$ring` with it, so the control's own edge is part of the ring rather
// than something the ring covers. It transitions, so this waits for it to arrive.
await page.waitForFunction((want: string) =>
getComputedStyle(document.querySelector('#button-light')!).borderTopColor === want,
rgb(roleOf(light, 'ring')));
// Motion is declared once, inside the query that asks whether the person wants any, at the
// tokens design 217 moved it to.
assert.equal(ringed.transitionDuration, '0.15s', '$fast, when motion is welcome');
assert.equal(ringed.transitionTimingFunction, 'cubic-bezier(0.4, 0, 0.2, 1)', '$ease');
await page.emulateMedia({ reducedMotion: 'reduce' });
const still = await paint('#button-light');
assert.equal(still.transitionDuration, '0s', 'and nothing at all when it is not');
await page.emulateMedia({ reducedMotion: 'no-preference' });
// --- axe and a Tab walk over the preview page --------------------------------------------------
const audited = await audit(page);
for (const violation of audited.violations) {
console.error(`axe ${violation.rule}: ${violation.help} (${String(violation.nodes.length)} node(s))`);
for (const node of violation.nodes) console.error(` ${node.html}`);
}
assert.equal(audited.violations.length, 0, 'axe found nothing to fix on the preview page');
console.log(`recipes/ui: axe passed ${String(audited.passes)} rules with no violation`);
// Every control on the page is reached by Tab, rings when it is, and lets the focus go on.
const walked = await tabWalk(page);
for (const problem of walked.problems) console.error(`walk ${problem.reason} at ${problem.target}: ${problem.fix}`);
assert.equal(walked.problems.length, 0, 'a Tab walk over the preview page found nothing to fix');
console.log(`recipes/ui: Tab stopped on ${String(walked.stops.length)} controls, every one with a ring`);
// --- the catalogue: one page per component, in both modes --------------------------------------
await page.goto(site.url + 'catalogue.html');
await page.waitForSelector('#catalogue');
// Read with motion turned off. A component's colours arrive on a transition when the page it is
// on is built (design 217), so a colour read in the frame after a navigation is a colour on its
// way somewhere. The motion itself is asserted on the preview page, where nothing navigates.
await page.emulateMedia({ reducedMotion: 'reduce' });
// Wide enough for the two panes to sit side by side, which is what the page is for. The gallery
// and the preview are read at the viewport the browser opened with.
await page.setViewportSize({ width: 1440, height: 900 });
const NAV = '#catalogue nav[aria-label="Components"]';
/** Go to a component's page through the nav, the way a person does. */
const show = async (name: string): Promise<void> => {
await page.click(`${NAV} a[href="#/${name}"]`);
await page.waitForSelector(`#${name}`);
};
// Every component the package exports has a page, and the ones that do not are named here
// with the reason (design 197). The list is read out of the surface file the gate regenerates,
// so a component added to `index.ts` and forgotten here turns this red.
const NO_EXAMPLE: Record<string, string> = {
Head: 'a head tag', Link: 'a head tag', Meta: 'a head tag', Script: 'a head tag',
Style: 'a head tag', Title: 'a head tag',
Theme: 'a provider', ThemeContext: 'a provider', Icons: 'a provider',
Countries: 'a provider, shown in the Country example',
InputContext: 'a provider', LoaderContext: 'a provider', PopupContext: 'a provider',
StageContext: 'a provider, and what this page routes on',
TextModifiers: 'a provider, shown in the Typography example',
ValidateContext: 'a provider, shown in the Validate example',
Shown: 'control flow, shown on the gallery page',
Switch: 'control flow, shown on the gallery page',
Stage: 'the stage, shown in the Modal example and used by this page',
Default: 'the stage template that adds nothing, shown in the Modal example',
Detached: 'the mechanism under the Popup and Tooltip examples',
Tab: 'shown in the Tabs example', TabPanel: 'shown in the Tabs example',
};
// The pages that show theme entries rather than a component. There is one: the form layout,
// which is entries and a bare label since design 209 withdrew `Field`.
const NO_COMPONENT: readonly string[] = ['Form'];
const exported = readFileSync(join(here, '..', '..', 'packages', 'ui', 'surface.txt'), 'utf8')
.split('\n')
.map((line) => /^value ([A-Z]\w*):/.exec(line)?.[1])
.filter((name): name is string => name !== undefined);
// One link per page, and the link is the page's URL: the name after `#/` is the act's name and
// the id of the article it renders (design 226). The nav is named, because an example may put a
// `<nav>` of its own on the page and a `Breadcrumb` does.
const links = await page.evaluate((selector: string) =>
Array.from(document.querySelectorAll(`${selector} a`)).map((node) => node.getAttribute('href')), NAV);
const listed = links.map((href) => String(href).replace(/^#\//, ''));
const missing = exported.filter((name) => !(name in NO_EXAMPLE) && !listed.includes(name));
assert.deepEqual(missing, [],
`every exported component has an example file: ${missing.join(', ')} has none`);
const stray = listed.filter((name) => !exported.includes(name) && !NO_COMPONENT.includes(name));
assert.deepEqual(stray, [], `and every example names a component the package exports: ${stray.join(', ')}`);
const files = readdirSync(join(here, 'examples')).filter((name) => name.endsWith('.example.tsx'));
assert.equal(files.length, listed.length,
`every file under examples/ has a page: ${String(files.length)} files, ${String(listed.length)} pages`);
// The nav is alphabetical. The order to compare against is the export list sorted, not the
// page's own order read back to itself.
const alphabetical = [...exported.filter((name) => !(name in NO_EXAMPLE)), ...NO_COMPONENT]
.sort((left, right) => left.localeCompare(right));
assert.deepEqual(listed, alphabetical, 'the nav is every component, in alphabetical order');
console.log(`recipes/ui: ${String(listed.length)} pages for ${String(exported.length)} exports, `
+ `${String(Object.keys(NO_EXAMPLE).length)} of them named as needing none`);
// The first page is the first component alphabetically, and the nav says which one it is.
assert.equal(await page.evaluate(() => document.querySelector('#catalogue main article')!.id),
alphabetical[0], 'a URL naming no component shows the first one');
assert.equal(await page.textContent(`${NAV} a[aria-current="page"]`), alphabetical[0],
'and exactly one link is marked as the page showing');
assert.equal(await page.evaluate((selector: string) =>
document.querySelectorAll(`${selector} a[aria-current]`).length, NAV), 1);
// The side bar scrolls, so the components below the fold are still reachable. The box is the
// viewport less the room above and below it, and what does not fit scrolls inside it. Measured
// at a short viewport, so the list is well past the bottom.
await page.setViewportSize({ width: 1440, height: 500 });
const boxed = await page.evaluate((selector: string) => {
const nav = document.querySelector(selector)!;
return {
height: Math.round(nav.getBoundingClientRect().height),
viewport: window.innerHeight,
scroll: nav.scrollHeight,
client: nav.clientHeight,
overflow: getComputedStyle(nav).overflowY,
};
}, NAV);
assert.ok(boxed.height <= boxed.viewport,
`the nav is no taller than the viewport: ${String(boxed.height)}px in ${String(boxed.viewport)}px`);
assert.equal(boxed.overflow, 'auto', 'and what does not fit scrolls inside it');
assert.ok(boxed.scroll > boxed.client,
`there is more list than box: ${String(boxed.scroll)}px of names in ${String(boxed.client)}px`);
const reached = await page.evaluate((selector: string) => {
const nav = document.querySelector(selector)!;
nav.scrollTop = nav.scrollHeight;
const last = nav.querySelector('li:last-child a')!;
const seen = last.getBoundingClientRect();
const around = nav.getBoundingClientRect();
return { name: last.textContent, inside: seen.top >= around.top - 1 && seen.bottom <= around.bottom + 1 };
}, NAV);
assert.equal(reached.name, listed[listed.length - 1]);
assert.ok(reached.inside, `and scrolling it reaches the last name: ${String(reached.name)}`);
await page.setViewportSize({ width: 1440, height: 900 });
// --- the search over the names -----------------------------------------------------------------
const showing = (): Promise<(string | null)[]> => page.evaluate((selector: string) =>
Array.from(document.querySelectorAll(`${selector} li:not([hidden])`)).map((node) => node.textContent), NAV);
assert.equal(await page.getAttribute('#catalogue-count', 'hidden'), '',
'the count says nothing until the list is narrowed');
await page.fill('#catalogue-search', 'tex');
await page.waitForFunction((selector: string) =>
document.querySelectorAll(`${selector} li:not([hidden])`).length === 2, NAV);
assert.deepEqual(await showing(), ['TextArea', 'TextField'],
'typing narrows the nav to the names that match');
assert.equal(await page.getAttribute('#catalogue-count', 'hidden'), null);
assert.equal(await page.textContent('#catalogue-count'), `2 of ${String(listed.length)}`,
'and the count says how many of them are left');
assert.equal(await page.getAttribute('#catalogue-none', 'hidden'), '',
'two is not nothing, so the line that says nothing matches is not there');
await page.fill('#catalogue-search', 'zzz');
await page.waitForFunction((selector: string) =>
document.querySelectorAll(`${selector} li:not([hidden])`).length === 0, NAV);
assert.equal(await page.getAttribute('#catalogue-none', 'hidden'), null);
assert.equal(await page.textContent('#catalogue-none'), 'Nothing matches that.');
await page.press('#catalogue-search', 'Escape');
await page.waitForFunction((count: number) =>
document.querySelectorAll('#catalogue nav[aria-label="Components"] li:not([hidden])').length === count,
listed.length);
assert.equal(await page.inputValue('#catalogue-search'), '', 'Escape empties the field');
// A page opened from the keyboard takes the focus without wearing the ring: the ring is for a
// control, and a whole page framed in it after every key is noise.
await page.focus('#catalogue nav a:not([aria-current="page"])');
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.activeElement?.tagName === 'ARTICLE');
const framed = await page.evaluate(() => {
const cs = getComputedStyle(document.activeElement!);
return { shadow: cs.boxShadow, outline: cs.outlineStyle, visible: document.activeElement!.matches(':focus-visible') };
});
assert.deepEqual(framed, { shadow: 'none', outline: 'none', visible: true },
'the page has the focus and no ring');
assert.equal(await page.getAttribute('#catalogue-count', 'hidden'), '', 'and the count goes quiet again');
// --- a URL is the page, and back is the router's -------------------------------------------------
// The URL a person copies, loaded from nothing. The hash never reaches the server, so what is
// asked for is `catalogue.html` and the router reads the rest (design 226).
await page.goto(`${site.url}catalogue.html#/Select`);
const reloaded = await page.reload();
assert.equal(reloaded?.status(), 200, 'the server was asked for a page it has');
await page.waitForSelector('#Select');
assert.equal(await page.evaluate(() => document.querySelector('#catalogue main article')!.id), 'Select',
'and the URL opened that component\'s page');
assert.equal(await page.textContent(`${NAV} a[aria-current="page"]`), 'Select');
await show('Table');
await page.goBack();
await page.waitForSelector('#Select');
assert.ok(page.url().endsWith('#/Select'),
`back is one page, through the entry the nav pushed: ${page.url()}`);
assert.equal(await page.textContent(`${NAV} a[aria-current="page"]`), 'Select',
'and the nav followed it');
// --- what each component's page says -------------------------------------------------------------
await show('Alert');
const alerts = await page.evaluate(() => ({
plain: document.querySelector('#alert-light')!.getAttribute('role'),
bad: document.querySelector('#alert-danger-light')!.getAttribute('role'),
}));
assert.deepEqual(alerts, { plain: 'status', bad: 'alert' },
'a danger alert interrupts a screen reader and the rest wait their turn');
// The picture is a data URL, so it has loaded by now and the letters are gone; the one with no
// src never made an image at all.
await show('Avatar');
await page.waitForFunction(() => !document.querySelector('#avatar-light img')!.hasAttribute('hidden'));
const avatars = await page.evaluate(() => ({
letters: document.querySelector('#avatar-light span')!.hasAttribute('hidden'),
images: document.querySelectorAll('#avatar-fallback-light img').length,
text: document.querySelector('#avatar-fallback-light')!.textContent,
}));
assert.deepEqual(avatars, { letters: true, images: 0, text: 'AB' },
'a picture that loaded hides its letters, and an avatar with no src never made an image');
await show('Badge');
const badges = await page.evaluate(() => ({
light: getComputedStyle(document.querySelector('#badge-light')!).backgroundColor,
dark: getComputedStyle(document.querySelector('#badge-dark')!).backgroundColor,
outline: getComputedStyle(document.querySelector('#badge-outline-light')!).backgroundColor,
}));
assert.notEqual(badges.light, badges.dark, 'a Badge resolves the mode of the pane it is in');
assert.equal(badges.outline, 'rgba(0, 0, 0, 0)', 'and an outline badge has no fill at all');
// The breadcrumb: the last level is where you are, and the separators are drawn rather than
// written, so this page needs no icon pack to have them.
await show('Breadcrumb');
const trail = await page.evaluate(() => {
const nav = document.querySelector('#breadcrumb-light')!;
const marks = Array.from(nav.querySelectorAll('[aria-hidden="true"]'));
const current = nav.querySelector('[aria-current="page"]')!;
return {
links: nav.querySelectorAll('a').length,
marks: marks.length,
drawings: nav.querySelectorAll('svg').length,
// The border box, because `getBoundingClientRect` is the turned one and a quarter turn
// makes an 8px square measure 11.3.
side: marks[0]!.offsetWidth,
current: current.textContent,
name: nav.getAttribute('aria-label'),
};
});
assert.equal(trail.links, 2, 'two of the three levels are links');
assert.equal(trail.marks, 2, 'and there is one separator between each pair');
assert.equal(trail.drawings, 0, 'nothing asked the Icons stack for a chevron');
assert.equal(trail.side, 8, 'the separator is a $chevron box');
assert.equal(trail.current, 'shot.png');
assert.equal(trail.name, 'Breadcrumb', 'the nav is named, so a screen reader can list it');
await show('Button');
// The control is the native element, in both modes, and neither one is drawn out of divs.
const buttons = await page.evaluate(() => ({
button: document.querySelector('#button-light')!.tagName.toLowerCase(),
link: document.querySelector('#button-link-light')!.tagName.toLowerCase(),
dark: document.querySelector('#button-dark')!.tagName.toLowerCase(),
light: getComputedStyle(document.querySelector('#button-light')!).backgroundColor,
darkFill: getComputedStyle(document.querySelector('#button-dark')!).backgroundColor,
}));
assert.equal(buttons.button, 'button', 'a Button is a real button');
assert.equal(buttons.link, 'a', 'and one given an href is an anchor');
assert.equal(buttons.dark, 'button');
assert.notEqual(buttons.light, buttons.darkFill, 'a Button resolves the mode of the pane it is in');
// A promise the click handler returns disables the button while it is out.
assert.equal(await page.getAttribute('#button-loading-light', 'disabled'), null);
await page.click('#button-toggle-busy-light');
assert.equal(await page.getAttribute('#button-loading-light', 'disabled'), '',
'the loading cell disabled the button');
await page.click('#button-toggle-busy-light');
// The size axis: three heights on the button, and a square at each of them (design 194).
const axis = await page.evaluate(() => {
const of = (id: string): [number, number] => {
const box = document.querySelector(`#${id}`)!.getBoundingClientRect();
return [Math.round(box.width), Math.round(box.height)];
};
return {
sm: of('button-sm-light'), md: of('button-md-light'), lg: of('button-lg-light'),
squareSm: of('square-sm-light'), squareMd: of('square-md-light'), squareLg: of('square-lg-light'),
};
});
assert.equal(axis.sm[1], 32, '$controlSm is 32px');
assert.equal(axis.md[1], 36, '$control is 36px');
assert.equal(axis.lg[1], 40, '$controlLg is 40px');
assert.deepEqual(axis.squareSm, [32, 32], 'an icon button is a square at every size');
assert.deepEqual(axis.squareMd, [36, 36]);
assert.deepEqual(axis.squareLg, [40, 40]);
await show('Card');
const card = await page.evaluate(() =>
Array.from(document.querySelector('#card-light')!.querySelectorAll(':scope > div'))
.map((node) => (node.getAttribute('class') ?? '') !== ''));
assert.deepEqual(card, [true, true, true], 'a card is a head, a body and a foot, each on its own part');
// A card with none of the three parts is the bare block, and its children are its own children.
const bareCard = await page.evaluate(() =>
Array.from(document.querySelectorAll('#paper-light > *')).map((node) => node.tagName.toLowerCase()));
assert.deepEqual(bareCard, ['p', 'p'],
'a card with no title, description or foot builds no head, body or foot (design 211)');
await show('Checkbox');
assert.equal(await page.evaluate(() => document.querySelector('#checkbox-light')!.tagName.toLowerCase()),
'input', 'a Checkbox is a real tick box');
// The picker is a drawn square and one or two real range inputs (design 222). The hue is native,
// so End on it is the platform's; the plane is this package's, so a real pointer and a real key
// on the thumb are what say it works.
await show('ColorPicker');
// Not "no drawn slider": the picker's thumbs are `<span role="slider">` by design (design 222),
// so this counts the divs, which nothing may be, and the spans, which are the thumbs and nothing
// else on this page.
const ranges = await page.evaluate(() => ({
divRange: document.querySelectorAll('div[role="slider"]').length,
divCheckbox: document.querySelectorAll('div[role="checkbox"]').length,
thumbs: document.querySelectorAll('span[role="slider"]').length,
}));
assert.deepEqual(ranges, { divRange: 0, divCheckbox: 0, thumbs: 6 },
'no range control or tick box is drawn out of a div, and every range role that is not an input is '
+ 'a ColorPicker thumb');
const knobs = await page.evaluate(() =>
Array.from(document.querySelectorAll('#picker-light input')).map((node) => node.getAttribute('type')));
assert.deepEqual(knobs, ['range'], 'one slider, the hue, because hasAlpha is false');
assert.equal(await page.locator('#picker-alpha-light input').count(), 2, 'and the opacity with alpha');
// Three pickers on the page, each with one thumb, and every one of them a span rather than a div.
const thumbs = await page.evaluate(() =>
Array.from(document.querySelectorAll('#catalogue span[role="slider"]'))
.map((node) => node.getAttribute('aria-valuetext')));
assert.equal(thumbs.length, 6, 'three pickers in each of the two modes, one thumb each');
assert.match(thumbs[0] ?? '', /^saturation \d+%, brightness \d+%$/,
'and a thumb says both axes, because there is no two-axis role');
// The pointer is driven in viewport coordinates, so the square has to be on the screen first.
await page.locator('#picker-light span[role="slider"]').scrollIntoViewIfNeeded();
const plane = await page.evaluate(() => {
const box = document.querySelector('#picker-light span[role="slider"]')!
.parentElement!.getBoundingClientRect();
return { x: box.left, y: box.top, width: Math.round(box.width), height: Math.round(box.height) };
});
assert.equal(plane.width, 160, 'the plane is $planeSize square');
assert.equal(plane.height, 160);
const started = await page.textContent('#picked-light') ?? '';
await page.mouse.move(plane.x + 40, plane.y + 40);
await page.mouse.down();
await page.mouse.move(plane.x + 120, plane.y + 120);
await page.mouse.up();
await page.waitForFunction((was: string) => document.querySelector('#picked-light')!.textContent !== was,
started);
assert.equal(await page.getAttribute('#picker-light span[role="slider"]', 'aria-valuetext'),
'saturation 75%, brightness 25%', 'a real drag put the thumb three quarters across and down');
assert.match(await page.textContent('#picked-light') ?? '', /^rgb\(/,
'and wrote the cell back as rgb() text');
const dragged = await page.textContent('#picked-light') ?? '';
await page.focus('#picker-light span[role="slider"]');
await page.keyboard.press('ArrowRight');
await page.waitForFunction((was: string) => document.querySelector('#picked-light')!.textContent !== was,
dragged);
assert.equal(await page.getAttribute('#picker-light span[role="slider"]', 'aria-valuetext'),
'saturation 76%, brightness 25%', 'and an arrow on the thumb is one step of saturation');
const hue = await page.textContent('#picked-light') ?? '';
await page.focus('#picker-light input[type=range]');
await page.keyboard.press('End');
await page.waitForFunction((was: string) => document.querySelector('#picked-light')!.textContent !== was,
hue);
assert.match(await page.textContent('#picked-light') ?? '', /^rgb\(/,
'the hue slider still writes the cell');
// A disclosure opens in the page's flow, and the platform's own keyboard does it.
await show('DropDown');
assert.equal(await page.evaluate(() => document.querySelector('#dropdown-light')!
.contains(document.querySelector('#dropdown-content-light'))), true,
'the content is inside the details, not in the popup sink');
const shut = await page.evaluate(() =>
document.querySelector('#dropdown-light')!.getBoundingClientRect().height);
await page.focus('#dropdown-light summary');
await page.keyboard.press('Space');
await page.waitForFunction(() => document.querySelector('#dropdown-light')!.hasAttribute('open'));
const shownHeight = await page.evaluate(() =>
document.querySelector('#dropdown-light')!.getBoundingClientRect().height);
assert.ok(shownHeight > shut,
`an open disclosure pushes the page down rather than floating over it: ${String(shut)} to ${String(shownHeight)}`);
await show('Empty');
assert.equal(await page.textContent('#empty-bare-light'), 'Nothing to see',
'an empty state renders only the parts it was given');
// A real file, through the input the zone hides but keeps focusable.
await show('FileDrop');
await page.setInputFiles('#filedrop-light input[type=file]', {
name: 'shot.png', mimeType: 'image/png', buffer: Buffer.from('not really a png'),
});
await page.waitForSelector('#filedrop-light li');
assert.match(await page.textContent('#filedrop-light li') ?? '', /shot\.png/,
'the entry is listed with its name');
const hidden = await page.evaluate(() => {
const input = document.querySelector('#filedrop-light input[type=file]')!;
const style = getComputedStyle(input);
return { display: style['display'], width: input.getBoundingClientRect().width };
});
assert.notEqual(hidden.display, 'none', 'the input is off the screen, not out of the focus order');
assert.ok(hidden.width < 4, `and it takes no room: ${String(hidden.width)}px`);
// A form laid out by the theme entries and a bare label (design 209). A pane on this page is
// wider than 28rem, so the responsive field is a row here; the narrow case is measured in
// `packages/ui/tests/browser.test.ts`, where the group's width can be moved.
await show('Form');
const form = await page.evaluate(() => {
const responsive = document.querySelector('#field-responsive-light')!;
const inline = document.querySelector('#field-inline-light')!;
const set = document.querySelector('#fieldset-light')!;
return {
group: getComputedStyle(document.querySelector('#form-light')!).containerType,
wide: Math.round(document.querySelector('#form-light')!.getBoundingClientRect().width),
responsive: getComputedStyle(responsive).flexDirection,
inline: getComputedStyle(inline).flexDirection,
legend: set.querySelector('legend')!.textContent,
border: getComputedStyle(set).borderTopStyle,
};
});
assert.equal(form.group, 'inline-size', 'the group is the container a responsive field measures');
assert.ok(form.wide > 448, `and this pane is wider than 28rem: ${String(form.wide)}px`);
assert.equal(form.responsive, 'row', 'so the responsive field is a row at this width');
assert.equal(form.inline, 'row', 'and the inline one is a row at every width');
assert.equal(form.legend, 'Where to send it');
assert.equal(form.border, 'none', 'and the fieldset draws no frame of its own');
// An icon is one svg element, sized in em so it follows the text beside it.
await show('Icon');
const icons = await page.evaluate(() => {
const one = document.querySelector('#icon-check-light')!;
const big = document.querySelector('#icon-big-light')!;
return {
tag: one.tagName.toLowerCase(),
children: one.querySelectorAll('svg').length,
named: document.querySelector('#icon-named-light')?.tagName.toLowerCase() ?? null,
label: one.getAttribute('aria-label'),
size: getComputedStyle(one).width,
big: getComputedStyle(big).width,
};
});
assert.equal(icons.tag, 'svg', 'an icon is an svg element');
assert.equal(icons.children, 0, 'one svg per icon, not one wrapping another');
assert.equal(icons.label, 'done');
assert.equal(icons.size, '16px', '1em of the 16px body text around it');
assert.equal(icons.big, '32px', 'and a size prop overrides it');
assert.equal(icons.named, 'svg', 'a bare name found its drawing in the set the page installed');
// The menu: a heading over four actions, the last of them in the danger colour (design 225).
await show('Menu');
await page.click('#menu-light');
await page.waitForFunction(() => {
const menu = document.querySelector('#menu-light-list');
return menu !== null && menu.getBoundingClientRect().height > 0
&& getComputedStyle(menu)['transform'] === 'none'
&& getComputedStyle(menu)['visibility'] === 'visible';
});
const actions = await page.evaluate(() => {
const menu = document.querySelector('#menu-light-list')!;
const rows = Array.from(menu.querySelectorAll('[role="menuitem"]'));
const heading = menu.querySelector('[role="group"]')!.getAttribute('aria-labelledby') ?? '';
return {
labels: rows.map((row) => row.textContent),
heading: document.querySelector(`[id="${heading}"]`)?.textContent ?? null,
danger: getComputedStyle(rows[rows.length - 1]!)['color'],
plain: getComputedStyle(rows[0]!)['color'],
};
});
assert.deepEqual(actions.labels,
['Mute Conversation', 'Mark as Read', 'Block User', 'Delete Conversation']);
assert.equal(actions.heading, 'Conversation', 'the group is named by its own heading');
assert.equal(actions.danger, rgb(roleOf(light, 'danger')), 'the last row computes $danger');
assert.notEqual(actions.plain, actions.danger, 'and the rest of them do not');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Enter');
assert.equal(await page.textContent('#menu-done-light'), 'deleted',
'the row the keyboard was on is the one whose onSelect ran');
// A modal is opened by the stage, and every way of closing it is the stage's close. The dialog
// is found by its element rather than by an id: a `Modal` writes only the props it names, so an
// id carried through `open` reaches the act and not the element (design 213), and one modal is
// open at a time.
await show('Modal');
assert.equal(await page.locator('dialog').count(), 0, 'no modal before anybody asked for one');
await page.click('#open-modal-light');
await page.waitForSelector('#editing-light');
const modal = await page.evaluate(() => {
const dialog = document.querySelector('dialog')!;
return {
tag: dialog.tagName.toLowerCase(),
modal: dialog.matches(':modal'),
named: document.querySelector(`#${dialog.getAttribute('aria-labelledby') ?? ''}`)?.textContent ?? '',
};
});
assert.deepEqual(modal, { tag: 'dialog', modal: true, named: 'Edit the thing' },
'a real <dialog>, showing as a modal, with a name a screen reader can read');
await page.click('dialog button[aria-label="Close"]');
await page.waitForFunction(() => document.querySelector('#editing-light') === null);
// A sheet, opened through the stage the way the modal beside it is. A modal dialog is in the top
// layer, so what it is measured against is the viewport rather than the pane it was opened from.
await page.click('#open-sheet-light');
await page.waitForSelector('#filtering-light');
const edge = await page.evaluate(() => {
const panel = document.querySelector('dialog')!;
const box = panel.getBoundingClientRect();
return {
tag: panel.tagName.toLowerCase(),
modal: panel.matches(':modal'),
width: Math.round(box.width),
right: Math.round(window.innerWidth - box.right),
height: Math.round(box.height),
viewport: window.innerHeight,
};
});
assert.equal(edge.tag, 'dialog', 'a sheet is the same element a modal is');
assert.ok(edge.modal, 'and it is showing as a modal');
assert.equal(edge.width, 384, '$sheetWidth: 24rem');
assert.equal(edge.right, 0, 'against the right edge');
assert.equal(edge.height, edge.viewport, 'and as tall as the viewport');
await page.click('dialog button[aria-label="Close"]');
await page.waitForFunction(() => document.querySelector('#filtering-light') === null);
// The pagination window at page 5 of 12, and a real click moving it.
await show('Pagination');
const pages = await page.evaluate(() =>
Array.from(document.querySelector('#pagination-light')!.querySelectorAll(':scope > *'))
.map((node) => node.textContent));
assert.deepEqual(pages, ['Previous', '1', '…', '4', '5', '6', '…', '12', 'Next'],
'first, last, and a sibling each side of the page showing now');
assert.equal(await page.getAttribute('#pagination-first-light button', 'disabled'), '',
'previous is off on the first page');
assert.equal(await page.textContent('#pagination-at-light'), '5');
await page.click('#pagination-light button[aria-current="page"] + button');
await page.waitForFunction(() => document.querySelector('#pagination-at-light')!.textContent === '6');
// `position` is the progress element's own property, which the narrow element shape this
// project compiles against does not name.
await show('Progress');
const positionOf = (id: string): Promise<number> => page.evaluate((held: string) =>
(document.querySelector(`#${held}`) as unknown as { position: number }).position, id);
assert.equal(await positionOf('progress-light'), 0.5, 'the progress element is a fraction of one');
await page.click('#progress-more-light');
assert.equal(await positionOf('progress-light'), 0.75, 'and the cell moves it');
assert.equal(await positionOf('progress-waiting-light'), -1,
'while one with no value at all is indeterminate');
await show('Radio');
assert.equal(await page.evaluate(() => document.querySelector('#radio-small-light')!.tagName.toLowerCase()),
'input', 'a Radio is a real radio button');
// The select draws its own arrow inside its own box (design 195), so the height is unchanged
// by it and the host's is nowhere on the page. The arrow is an empty box with two of its sides
// drawn, turned a quarter turn, so this page needs no icon pack to have one. The control itself
// is a `<button role="combobox">` over a hidden `<select>` since design 224.
await show('Select');
const arrows = await page.evaluate(() => {
const select = document.querySelector('#select-light')!;
const wrap = select.parentElement!;
const arrow = wrap.querySelector('span')!;
const mark = arrow.getBoundingClientRect();
const box = select.getBoundingClientRect();
return {
tag: select.tagName.toLowerCase(),
height: Math.round(box.height),
large: Math.round(document.querySelector('#select-lg-light')!.getBoundingClientRect().height),
inset: getComputedStyle(arrow).right,
side: arrow.offsetWidth,
inside: mark.right <= box.right,
drawings: wrap.querySelectorAll('svg').length,
appearance: getComputedStyle(select).appearance,
};
});
assert.equal(arrows.tag, 'button', 'the closed control is a button (design 224)');
assert.equal(arrows.height, 36, 'a select with its arrow in it is still $control tall');
assert.equal(arrows.large, 40, 'and the size axis is one segment, so $controlLg reaches it too');
assert.equal(arrows.inset, '12px', 'the arrow sits $space3 in from the right edge');
assert.equal(arrows.side, 8, 'and it is a $chevron box');
assert.ok(arrows.inside, 'and inside the control');
assert.equal(arrows.drawings, 0, 'nothing on the page asked the Icons stack for an arrow');
assert.equal(arrows.appearance, 'none',
'the host draws nothing of its own, and the open list is this package\'s (design 224)');
// The drawn list (design 224). It opens under the control, it is the control's width, and the
// keys are the listbox behaviour's (design 223).
// Every select on the page has a list of its own in the popup sink, so each is reached by the id
// its own control names rather than by the role.
await page.click('#select-open-light');
// Placed, not only laid out: `Detached` keeps a popup hidden for the frame between opening
// and knowing where it goes, and a read in that frame sees it at the top of the page.
await page.waitForFunction(() => {
const list = document.querySelector('#select-open-light-list');
return list !== null && list.getBoundingClientRect().width > 0
&& getComputedStyle(list)['transform'] === 'none'
&& getComputedStyle(list)['visibility'] === 'visible';
});
const drawn = await page.evaluate(() => {
const control = document.querySelector('#select-open-light')!.getBoundingClientRect();
const list = document.querySelector('#select-open-light-list')!;
const box = list.getBoundingClientRect();
return {
below: Math.round(box.top) >= Math.round(control.top + control.height) - 1,
width: Math.round(box.width),
control: Math.round(control.width),
rows: list.querySelectorAll('[role="option"]').length,
expanded: document.querySelector('#select-open-light')!.getAttribute('aria-expanded'),
};
});
assert.equal(drawn.below, true, `the open list sits under the control: ${JSON.stringify(drawn)}`);
assert.equal(drawn.width, drawn.control, 'and it is exactly as wide as the control');
assert.equal(drawn.rows, 4, 'four options, drawn by this package');
assert.equal(drawn.expanded, 'true');
// A letter moves to the first row that begins with it, and Enter takes the row it is on.
await page.keyboard.press('a');
const typed = await page.evaluate(() => {
const at = document.querySelector('#select-open-light')!.getAttribute('aria-activedescendant') ?? '';
return document.querySelector(`[id="${at}"]`)?.textContent ?? null;
});
assert.equal(typed, 'Apple', 'type-ahead moved to the first row beginning with the letter');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
assert.equal(await page.textContent('#select-open-light'), 'Apricot',
'ArrowDown then Enter picked the row after the one type-ahead found');
assert.equal(await page.getAttribute('#select-open-light', 'aria-expanded'), 'false',
'and picking closed the list');
// Escape closes it and leaves the focus where the person was.
await page.keyboard.press('ArrowDown');
await page.waitForFunction(() =>
document.querySelector('#select-open-light')!.getAttribute('aria-expanded') === 'true');
await page.keyboard.press('Escape');
assert.equal(await page.getAttribute('#select-open-light', 'aria-expanded'), 'false');
assert.equal(await page.evaluate(() => document.activeElement?.id), 'select-open-light',
'Escape gives the keyboard back to the control');
// The open cell is the page's too: the button beside it opens the same list, and a mousedown
// anywhere else closes it.
await page.click('#select-toggle-light');
await page.waitForFunction(() =>
document.querySelector('#select-open-light')!.getAttribute('aria-expanded') === 'true');
await page.click('#select-md-light');
await page.waitForFunction(() =>
document.querySelector('#select-open-light')!.getAttribute('aria-expanded') === 'false');
await page.keyboard.press('Escape');
await show('Skeleton');
assert.equal(await page.getAttribute('#skeleton-light', 'aria-hidden'), 'true',
'a skeleton says nothing to a screen reader');
// The slider's thumb and track are drawn from the theme on the vendor pseudo-elements. Chromium
// does not report a vendor pseudo-element through getComputedStyle, so what is read here is that
// the host was told not to draw its own and that the rule reached the page with the role in it.
await show('Slider');
const slider = await page.evaluate(() => {
const style = getComputedStyle(document.querySelector('#slider-light')!);
return {
tag: document.querySelector('#slider-light')!.tagName.toLowerCase(),
appearance: style.appearance,
height: style.height,
image: style.backgroundImage,
padding: [style.paddingLeft, style.paddingRight],
sheet: Array.from(document.head.querySelectorAll('style[data-aweft]'))
.map((style) => style.textContent ?? '').join('\n'),
};
});
assert.equal(slider.tag, 'input', 'a Slider is a real range input');
assert.equal(slider.appearance, 'none', 'the host is not drawing its own range input');
assert.equal(slider.height, '36px', 'and its hit area is $control tall');
assert.match(slider.sheet, new RegExp(`::-webkit-slider-thumb \\{[^}]*background: ${rgbHex(roleOf(light, 'accent'))}`),
'the thumb rule reached the page with $accent in it');
// Design 220: the state goes on the thumb and the track, so nothing is painted over the control
// itself, and the room at each end is what the thumb grows into.
assert.equal(slider.image, 'none', 'nothing is painted over the control at rest');
assert.deepEqual(slider.padding, ['4px', '4px'], '$space of room for the thumb at both ends');
await page.hover('#slider-light');
const hoveredSlider = await page.evaluate(() => ({
image: getComputedStyle(document.querySelector('#slider-light')!).backgroundImage,
sheet: Array.from(document.head.querySelectorAll('style[data-aweft]'))
.map((style) => style.textContent ?? '').join('\n'),
}));
assert.equal(hoveredSlider.image, 'none', 'and none over it under the pointer either');
assert.match(hoveredSlider.sheet, /::-webkit-slider-thumb \{ transform: scale\(1\.2\); \}/,
'the hovered chain scales the thumb instead');
await page.mouse.move(0, 0);
// The table's rows, its scroll box, and the entries used on their own with no component at all.
await show('Table');
const table = await page.evaluate(() => {
const box = document.querySelector('#table-light')!;
const scroll = box.parentElement!;
const markup = document.querySelector('#table-markup-light')!;
return {
rows: box.querySelectorAll('tbody tr').length,
headings: box.querySelectorAll('th[scope="col"]').length,
overflow: getComputedStyle(scroll).overflowX,
focusable: scroll.getAttribute('tabindex'),
caption: box.querySelector('caption')!.textContent,
foot: box.querySelector('tfoot td')!.getAttribute('colspan'),
byHand: getComputedStyle(markup).borderCollapse,
hairline: getComputedStyle(markup.querySelector('tbody tr')!).borderBottomWidth,
};
});
assert.equal(table.rows, 3, 'one row per file');
assert.equal(table.headings, 3, 'and one heading per column');
assert.equal(table.overflow, 'auto', 'the box a wide table scrolls in');
assert.equal(table.focusable, '0', 'and a keyboard can reach that scroll');
assert.equal(table.caption, 'Everything in this folder');
assert.equal(table.foot, '3', 'the foot spans every column');
assert.equal(table.byHand, 'collapse', 'the entries work on markup nobody generated');
assert.equal(table.hairline, '1px', '$borderWidth under a hand-written row');
// The tabs: a real arrow moves the focus and the selection together, and Home and End go to the
// ends. Design 203: the strip is one tab stop, so the keys are what move inside it.
await show('Tabs');
const strip = await page.evaluate(() => {
const box = document.querySelector('#tabs-light')!;
const tabs = Array.from(box.querySelectorAll('[role="tab"]'));
return {
tabs: tabs.length,
panels: box.querySelectorAll('[role="tabpanel"]').length,
hidden: Array.from(box.querySelectorAll('[role="tabpanel"]'))
.filter((node) => node.hasAttribute('hidden')).length,
roving: tabs.map((node) => node.getAttribute('tabindex')).join(' '),
named: tabs.every((node) =>
document.querySelector(`[id="${node.getAttribute('aria-controls') ?? ''}"]`) !== null),
height: Math.round(tabs[0]!.getBoundingClientRect().height),
};
});
assert.equal(strip.tabs, 3, 'one tab per view');
assert.equal(strip.panels, 3, 'and one panel per tab, all of them mounted');
assert.equal(strip.hidden, 2, 'with the two that are not showing hidden');
assert.equal(strip.roving, '0 -1 -1', 'one tab stop, on the tab showing');
assert.ok(strip.named, 'every tab names a panel that is really on the page');
assert.equal(strip.height, 36, '$control, the height every control is');
assert.equal(await page.textContent('#tabs-value-light'), 'all');
await page.focus('#tabs-light [role="tab"]');
await page.keyboard.press('ArrowRight');
await page.waitForFunction(() => document.querySelector('#tabs-value-light')!.textContent === 'mine');
const moved = await page.evaluate(() => {
const tabs = Array.from(document.querySelectorAll('#tabs-light [role="tab"]'));
return {
focused: document.activeElement!.textContent,
selected: tabs.filter((node) => node.getAttribute('aria-selected') === 'true').length,
roving: tabs.map((node) => node.getAttribute('tabindex')).join(' '),
};
});
assert.equal(moved.focused, 'Mine', 'the arrow moved the focus');
assert.equal(moved.selected, 1, 'and the selection moved with it, which is automatic activation');
assert.equal(moved.roving, '-1 0 -1', 'and the one tab stop went with them');
// End is the last tab that can be chosen, and the third one is disabled here.
await page.keyboard.press('End');
await page.waitForFunction(() => document.querySelector('#tabs-value-light')!.textContent === 'mine');
assert.equal(await page.evaluate(() => document.activeElement!.textContent), 'Mine',
'the disabled tab at the end is stepped over rather than landed on');
await page.keyboard.press('Home');
await page.waitForFunction(() => document.querySelector('#tabs-value-light')!.textContent === 'all');
// The line type draws a $ringWidth rail in $accent under the tab showing, and no fill behind it.
const rail = await page.evaluate(() => {
const tabs = Array.from(document.querySelectorAll('#tabs-line-light [role="tab"]'));
const chosen = tabs.find((node) => node.getAttribute('aria-selected') === 'true')!;
const other = tabs.find((node) => node.getAttribute('aria-selected') !== 'true')!;
return {
width: getComputedStyle(chosen).borderBottomWidth,
colour: getComputedStyle(chosen).borderBottomColor,
quiet: getComputedStyle(other).borderBottomColor,
fill: getComputedStyle(chosen).backgroundColor,
height: Math.round(chosen.getBoundingClientRect().height),
};
});
assert.equal(rail.width, '3px', '$ringWidth');
assert.equal(rail.colour, 'rgb(28, 32, 39)', '$accent, which is the foreground in this theme');
assert.equal(rail.quiet, 'rgba(0, 0, 0, 0)', 'and the tabs beside it carry a transparent one');
assert.equal(rail.fill, 'rgba(0, 0, 0, 0)', 'an underlined tab is not also a filled one');
assert.equal(rail.height, 32, '$controlSm, through the size the group handed down');
// The textarea measures itself and grows, which needs a layout and so belongs here.
await show('TextArea');
assert.equal(await page.evaluate(() => document.querySelector('#area-light')!.tagName.toLowerCase()),
'textarea', 'a TextArea is a real textarea');
const before = await page.evaluate(() =>
document.querySelector('#area-light')!.getBoundingClientRect().height);
await page.click('#area-light');
await page.keyboard.type('one\ntwo\nthree\nfour\nfive');
const after = await page.evaluate(() =>
document.querySelector('#area-light')!.getBoundingClientRect().height);
assert.ok(after > before, `the text area grew to its content: ${String(before)} to ${String(after)}`);
await show('TextField');
assert.equal(await page.evaluate(() => document.querySelector('#field-light')!.tagName.toLowerCase()),
'input', 'a TextField is a real input');
assert.equal(await page.evaluate(() =>
Math.round(document.querySelector('#field-sm-light')!.getBoundingClientRect().height)), 32,
'and the size axis is one segment, so $controlSm reaches it too');
// A real click on a real button, and the error it turns on reaches the field's ARIA.
assert.equal(await page.getAttribute('#field-invalid-light', 'aria-invalid'), null);
await page.click('#toggle-error-light');
assert.equal(await page.getAttribute('#field-invalid-light', 'aria-invalid'), 'true',
'the error cell drove aria-invalid');
const describedBy = await page.getAttribute('#field-invalid-light', 'aria-describedby') ?? '';
assert.match(await page.textContent(`#${describedBy}`) ?? '', /does not look like an address/,
'and aria-describedby names the message that arrived');
// The ring is on the box a `leading` builds, not on the input inside it (design 210).
await page.focus('#addon-light');
const grouped = await page.evaluate(() => {
const input = document.querySelector('#addon-light')!;
const box = input.parentElement!;
return {
box: getComputedStyle(box)['boxShadow'] ?? '',
input: getComputedStyle(input)['boxShadow'] ?? '',
height: Math.round(box.getBoundingClientRect().height),
};
});
assert.match(grouped.box, /0px 0px 0px 3px/, 'focusing the input rings the whole box');
assert.equal(grouped.input, 'none', 'and the input inside it shows none of its own');
assert.equal(grouped.height, 36, 'the box is $control tall, so it is the control');
await show('Toggle');
assert.equal(await page.evaluate(() => document.querySelector('#toggle-light')!.tagName.toLowerCase()),
'input', 'a Toggle is a real tick box wearing a switch');
// A tip on a real hover, and the anchor points at the panel. The box the solver placed is what
// reaches the top layer; the panel stays inside it and wears no popover of its own (design 135).
await show('Tooltip');
const tipId = await page.getAttribute('#tip-anchor-light', 'aria-describedby') ?? '';
assert.notEqual(tipId, '', 'the anchor names its tip');
assert.equal(await page.getAttribute(`#${tipId}`, 'role'), 'tooltip');
await page.hover('#tip-anchor-light');
await page.waitForFunction((id: string) =>
document.querySelector(`#${id}`)!.parentElement!.matches(':popover-open'), tipId);
assert.equal(await page.getAttribute(`#${tipId}`, 'popover'), null,
'the panel is not a popover: one inside the box would be laid out by the browser and leave it');
assert.equal(await page.evaluate((id: string) =>
document.querySelector(`#${id}`)!.parentElement!.getAttribute('popover'), tipId), 'manual',
'the box asked for the top layer, with no z-index anywhere');
await page.mouse.move(0, 0);
await page.waitForFunction((id: string) =>
!document.querySelector(`#${id}`)!.parentElement!.matches(':popover-open'), tipId);
// The two modes resolve their own roles, on the composites as on everything else.
const composedModes = await page.evaluate(() => ({
light: getComputedStyle(document.querySelector('#tip-anchor-light')!).backgroundColor,
dark: getComputedStyle(document.querySelector('#tip-anchor-dark')!).backgroundColor,
}));
assert.notEqual(composedModes.light, composedModes.dark, 'a composite resolves the mode of its pane');
// The signal is what starts the checking, and the context tallies the answers. The count is
// taken inside the page, because a control elsewhere may have a standing error.
await show('Validate');
assert.equal(await page.locator('#Validate [role="alert"]').count(), 0,
'a person typing is not a person who is wrong yet');
await page.click('#validate-email-light');
await page.keyboard.type('nope');
assert.equal(await page.locator('#Validate [role="alert"]').count(), 0);
await page.click('#submit-light');
await page.waitForSelector('#Validate [role="alert"]');
assert.equal(await page.textContent('#valid-light'), 'the form is not happy');
assert.equal(await page.getAttribute('#validate-email-light', 'aria-invalid'), 'true',
'and the control the wrapper holds says so itself');
await page.click('#validate-email-light');
await page.keyboard.press('Control+a');
await page.keyboard.type('ada@example.com');
await page.click('#validate-phone-light');
await page.keyboard.type('5195551234');
await page.click('#catalogue-title');
await page.waitForFunction(() => document.querySelector('#valid-light')!.textContent === 'the form is happy');
assert.equal(await page.evaluate(() => document.querySelector('#validate-phone-light')!.value),
'(519) 555-1234', 'a formatting validator wrote the value back punctuated');
// The country and subdivision fields: a dialog with a search over a grid, and a form that posts
// the two codes (designs 250 and 251).
await show('Country');
const closed = await page.evaluate(() => {
const button = document.querySelector('#country-light')!;
return {
expanded: button.getAttribute('aria-expanded'),
popup: button.getAttribute('aria-haspopup'),
open: document.querySelector('#country-light-dialog')!.hasAttribute('open'),
// The subdivision field has nothing to offer until a country is chosen.
region: document.querySelector('#region-light')!.hasAttribute('disabled'),
};
});
assert.equal(closed.popup, 'dialog', 'the control says what it opens');
assert.equal(closed.expanded, 'false');
assert.equal(closed.open, false, 'and the dialog is in the markup, closed');
assert.equal(closed.region, true, 'the province field waits for a country');
await page.click('#country-light');
await page.waitForFunction(() => document.querySelector('#country-light-dialog')!.hasAttribute('open'));
const dialog = await page.evaluate(() => {
const box = document.querySelector('#country-light-dialog')!;
const rows = Array.from(box.querySelectorAll('[role="option"]'));
const top = rows.filter((row) => row.getBoundingClientRect().top === rows[0]!.getBoundingClientRect().top);
return {
rows: rows.length,
columns: top.length,
names: rows.slice(0, 3).map((row) => row.textContent ?? ''),
// The suggested row wears a ring rather than a fill, because the fill already means the
// choice and the keyboard (design 250).
ring: getComputedStyle(rows[0]!).boxShadow,
next: getComputedStyle(rows[1]!).boxShadow,
focused: document.activeElement?.id ?? '',
};
});
assert.equal(dialog.rows, 249, 'every country the data has, in the grid');
assert.ok(dialog.columns > 1, `laid out in ${String(dialog.columns)} columns at this width`);
// The order design 251 settled: the country the browser's language points at, then the priority
// list in the order it was given, then everything else by name. This browser says `en-US`.
assert.match(dialog.names[0] ?? '', /United States/, 'the suggestion leads');
assert.match(dialog.names[1] ?? '', /Canada/, 'then the priority list, in the order it was given');
assert.match(dialog.names[2] ?? '', /United Kingdom/);
assert.notEqual(dialog.ring, 'none', 'and the suggested row is marked');
assert.equal(dialog.next, 'none', 'while the row under it is not');
assert.equal(dialog.focused, 'country-light-search', 'and the keyboard is in the search box');
// A modal holds the keyboard: Tab cycles inside the dialog and never reaches the page behind it.
// That is the platform's, and it is the reason this is a `<dialog>` rather than a popup
// (design 250). The cycle passes through the body between the last control and the first, which
// is the host's own way of wrapping and is not the focus leaving.
const walk: string[] = [];
for (let step = 0; step < 4; step += 1) {
await page.keyboard.press('Tab');
walk.push(await page.evaluate(() => {
const held = document.activeElement;
if (held === null || held.tagName === 'BODY') return 'the body';
return document.querySelector('#country-light-dialog')!.contains(held)
? `inside: ${held.tagName.toLowerCase()}` : `OUTSIDE: ${held.tagName.toLowerCase()}#${held.id}`;
}));
}
assert.equal(await page.evaluate(() =>
document.querySelector('#country-light-dialog')!.matches(':modal')), true,
'the dialog is modal, so the host itself holds the keyboard in it');
assert.deepEqual(walk.filter((where) => where.startsWith('OUTSIDE')), [],
`four tabs stayed in the dialog: ${walk.join(', ')}`);
assert.ok(walk.some((where) => where.startsWith('inside')), `and landed in it: ${walk.join(', ')}`);
// The search: an alias for a country whose code is not the letters typed.
await page.keyboard.type('uk');
await page.waitForFunction(() =>
document.querySelectorAll('#country-light-dialog [role="option"]').length < 10);
const searched = await page.evaluate(() =>
Array.from(document.querySelectorAll('#country-light-dialog [role="option"]'))
.map((row) => row.textContent ?? ''));
assert.ok(searched.some((text) => text.includes('United Kingdom')), `uk found ${searched.join(', ')}`);
await page.click('#country-light-dialog [role="option"]');
await page.waitForFunction(() => !document.querySelector('#country-light-dialog')!.hasAttribute('open'));
const picked = await page.evaluate(() => ({
label: document.querySelector('#country-light')!.textContent,
region: document.querySelector('#region-light')!.hasAttribute('disabled'),
focus: document.activeElement?.id ?? '',
}));
assert.match(String(picked.label), /United Kingdom/, 'the button reads as the country now');
assert.equal(picked.region, false, 'and the province field has something to offer');
assert.equal(picked.focus, 'country-light', 'the keyboard came back to the control that opened it');
// The longest subdivision list there is, searched down to one row.
await page.click('#region-light');
await page.waitForFunction(() =>
document.querySelectorAll('#region-light-dialog [role="option"]').length === 217);
await page.keyboard.type('aberdeen');
await page.waitForFunction(() =>
document.querySelectorAll('#region-light-dialog [role="option"]').length < 5);
await page.click('#region-light-dialog [role="option"]');
await page.waitForFunction(() => !document.querySelector('#region-light-dialog')!.hasAttribute('open'));
// The form posts the codes, not the names a person read.
await page.click('#country-send-light');
await page.waitForFunction(() => document.querySelector('#country-posted-light')!.textContent !== '');
const posted = await page.textContent('#country-posted-light');
assert.match(String(posted), /country=GB/, `the form posted ${String(posted)}`);
assert.match(String(posted), /region=ABE/, 'and the subdivision by its short code');
console.log(`recipes/ui: the address form posted ${String(posted)}`);
// The subdivision field on its own page: a country with nothing to offer, and the longest list
// the data has (design 251).
await show('Region');
const region = await page.evaluate(() => ({
off: document.querySelector('#region-none-light')!.hasAttribute('disabled'),
on: document.querySelector('#region-ca-light')!.hasAttribute('disabled'),
chosen: document.querySelector('#region-us-light')!.textContent,
}));
assert.equal(region.off, true, 'with no country there is nothing to open');
assert.equal(region.on, false, 'and with one there is');
assert.match(String(region.chosen), /New York/, 'a chosen subdivision reads as its name');
await page.click('#region-gb-light');
await page.waitForFunction(() =>
document.querySelectorAll('#region-gb-light-dialog [role="option"]').length === 217);
await page.keyboard.type('aberdeen');
await page.waitForFunction(() =>
document.querySelectorAll('#region-gb-light-dialog [role="option"]').length < 5);
const cut = await page.evaluate(() =>
document.querySelectorAll('#region-gb-light-dialog [role="option"]').length);
console.log(`recipes/ui: 217 council areas searched down to ${String(cut)}`);
await page.keyboard.press('Escape');
await page.waitForFunction(() =>
!document.querySelector('#region-gb-light-dialog')!.hasAttribute('open'));
// --- axe over every page, with both modes showing ------------------------------------------------
/** Whatever page is showing, audited whole. */
const audits = async (what: string): Promise<number> => {
const found = await audit(page);
for (const violation of found.violations) {
console.error(`axe ${violation.rule}: ${violation.help} (${String(violation.nodes.length)} node(s))`);
for (const node of violation.nodes) console.error(` ${node.html}`);
}
assert.equal(found.violations.length, 0, `axe found nothing to fix ${what}`);
return found.passes;
};
let rules = 0;
let stops = 0;
for (const name of listed) {
await show(name);
rules = await audits(`on the ${name} page`);
// Tab through the page: every control reached, ringed where it lands, and let go of. This
// is the walk over every component the package ships, in both modes at once.
const walked = await tabWalk(page);
for (const problem of walked.problems) console.error(`walk ${problem.reason} at ${problem.target}: ${problem.fix}`);
assert.equal(walked.problems.length, 0, `a Tab walk over the ${name} page found nothing to fix`);
stops += walked.stops.length;
// Every control is the native element. The tags are checked one page at a time above; this
// is the sweep that says no page anywhere drew a slider or a tick box out of divs.
const drawn = await page.evaluate(() =>
document.querySelectorAll('#catalogue div[role="slider"], #catalogue div[role="checkbox"]').length);
assert.equal(drawn, 0, `nothing on the ${name} page is a drawn slider or a drawn tick box`);
}
console.log(`recipes/ui: axe passed ${String(listed.length)} catalogue pages, `
+ `${String(rules)} rules on the last of them, with no violation; Tab stopped on ${String(stops)} controls across them`);
// And again with a popup list open, because that markup is this package's and nothing else on
// the page audits it (designs 224, 225). Twice rather than once: a mousedown outside a list is
// what closes one, so the select and the menu are on two pages and audited on each.
await show('Select');
await page.click('#select-open-light');
await page.waitForFunction(() =>
document.querySelector('#select-open-light-list')!.getBoundingClientRect().height > 0);
await audits('with a select open');
await show('Menu');
await page.click('#menu-light');
await page.waitForFunction(() =>
document.querySelector('#menu-light-list')!.getBoundingClientRect().height > 0);
const opened = await audits('with a menu open');
console.log(`recipes/ui: axe passed ${String(opened)} rules with a drawn list open`);
// And once more opened from the keyboard, which is the shape that carries the active row: the
// menu takes the focus and names the row from there, because ARIA refuses the attribute on the
// `role="button"` that opened it (designs 223, 225). A click alone never reaches that markup.
await page.keyboard.press('Escape');
await page.waitForFunction(() =>
document.querySelector('#menu-light')!.getAttribute('aria-expanded') === 'false');
await page.focus('#menu-light');
await page.keyboard.press('ArrowDown');
// The active row is named before the focus moves, so the wait covers both: asserting the focus
// the moment the row appears reads the anchor, or nothing, on a slow frame.
await page.waitForFunction(() => {
const menu = document.querySelector('#menu-light-list');
return menu !== null && menu.getBoundingClientRect().height > 0
&& menu.getAttribute('aria-activedescendant') !== null
&& document.activeElement === menu;
});
assert.equal(await page.getAttribute('#menu-light', 'aria-activedescendant'), null,
'the anchor names no active row, which is the attribute axe refuses on a button');
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute('role')), 'menu',
'the focus is on the menu, which is where the pattern puts it');
const keyed = await audits('with a menu opened from the keyboard');
console.log(`recipes/ui: axe passed ${String(keyed)} rules with a menu open under the keyboard`);
// And with a dialog open over a grid of options, which is the markup designs 250 and 251 add: a
// modal holding a combobox that names its active row, over a listbox nobody has focused. Last,
// because a modal takes the rest of the page out of reach and nothing after it could be driven.
await show('Country');
await page.click('#country-light');
await page.waitForFunction(() =>
document.querySelector('#country-light-dialog')!.hasAttribute('open')
&& document.activeElement?.id === 'country-light-search');
await page.keyboard.press('ArrowDown');
await page.waitForFunction(() =>
document.querySelector('#country-light-search')!.getAttribute('aria-activedescendant') !== null);
const grid = await audits('with a country dialog open');
console.log(`recipes/ui: axe passed ${String(grid)} rules with a searched grid open`);
assert.deepEqual(problems, [], 'the page threw nothing');
console.log('recipes/ui: ok');
} finally {
await browser.close();
await site.close();
}