diff --git a/css/kit.css b/css/kit.css index f4b7ab6..a8f914e 100644 --- a/css/kit.css +++ b/css/kit.css @@ -407,6 +407,113 @@ a:hover { display: none !important; } +/* -- Bottom bar ----------------------------------------------------------------- + kit.ui.mountShell({ statusBar }) adds it after the frame, under the rail and + the sections, hidden until shown; while it shows, the frame takes the rest + of the window: + + .app-frame.kit-with-statusbar the window less the bar + footer.kit-statusbar 26px, a line along its top + ├── button.kit-statusbar-item an item, filled by the app + └── .kit-statusbar-panel[role=dialog] its panel, over the page above it, + ├── button.kit-statusbar-close Close, at its top right + └── .kit-statusbar-panel-body filled by the app */ +.app-frame.kit-with-statusbar { + height: calc(100vh - 26px); +} + +.kit-statusbar { + display: flex; + align-items: center; + gap: 0.25rem; + height: 26px; + padding: 0 0.4rem; + border-top: 1px solid var(--bs-border-color); + background-color: var(--bs-tertiary-bg); + font-size: 0.75rem; +} + +/* Borderless, washed in the accent when hovered and while its panel is open. + Its ring sits close, inside the bar's height. */ +.kit-statusbar-item { + display: inline-flex; + align-items: center; + gap: 0.55rem; + height: 22px; + padding: 0 0.5rem; + border: 0; + border-radius: var(--radius-control); + background: none; + color: var(--bs-body-color); + font: inherit; + white-space: nowrap; + cursor: pointer; + transition: background-color var(--dur-micro) var(--ease-micro); +} + +.kit-statusbar-item:hover, +.kit-statusbar-item[aria-expanded="true"] { + background-color: rgba(var(--accent-rgb), var(--wash-hover-fill)); +} + +.kit-statusbar-item:focus-visible { + outline-offset: 0; +} + +.kit-statusbar-item .kit-card-status { + min-height: 0; +} + +/* The panel: placed by the kit over its item, its width the app's (capped to + the window). It takes the focus as it opens, so the panel itself draws no + ring; its controls do. */ +.kit-statusbar-panel { + position: fixed; + z-index: 1070; + padding: 0.8rem 0.9rem; + border: 1px solid var(--bs-border-color); + border-radius: var(--radius-card); + background-color: var(--bs-body-bg); + color: var(--bs-body-color); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2); +} + +.kit-statusbar-panel:focus { + outline: none; +} + +.kit-statusbar-panel-body { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +/* The app's first line leaves room for Close beside it. */ +.kit-statusbar-panel-body > :first-child { + margin-inline-end: calc(18px + 0.5rem); +} + +.kit-statusbar-close { + position: absolute; + top: 0.8rem; + right: 0.9rem; + display: inline-flex; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: none; + color: var(--bs-secondary-color); + cursor: pointer; +} + +.kit-statusbar-close:hover { + color: var(--bs-body-color); +} + +.kit-statusbar-close .material-icons-round { + font-size: 18px; +} + /* -- Nav rail --------------------------------------------------------------- */ .nav-rail { diff --git a/demo/src/renderer.js b/demo/src/renderer.js index 1acfcc2..81b2788 100644 --- a/demo/src/renderer.js +++ b/demo/src/renderer.js @@ -250,6 +250,94 @@ function fillControls() { $("sample-steps").replaceWith(slider.element); } +// -- The bottom bar --------------------------------------------------------------- +// +// One item, hidden until a test shows the bar (shell.statusBar.show(true)): how +// the Overview's checks went, with a panel listing each and Check Again. + +/** The Overview's checks: each row's name, and the cell its result is in (the theme isn't one). */ +const CHECKS = [ + ["Shared Bridge", "kit-api"], + ["App Bridge", "electron-api"], + ["Sandboxed", "sandboxed"], + ["Page Library", "kit-js"], + ["Kit Styles", "kit-css"], +]; + +/** A check that failed says so: "Missing", "No", or "Failed: …". */ +const FAILED = /^(Missing|No|Failed)\b/; + +/** Run the Overview's checks, each into its row. */ +function runChecks() { + return Promise.all([ + show("kit-api", async () => (window.kitAPI ? `App version ${await window.kitAPI.getVersion()}` : "Missing")), + show("electron-api", async () => (window.electronAPI ? `Electron ${await window.electronAPI.getElectronVersion()}` : "Missing")), + show("sandboxed", () => (window.electronAPI?.sandboxed ? "Yes" : "No")), + show("kit-js", () => (window.kit ? "Loaded" : "Missing")), + // A token only kit.css sets. + show("kit-css", () => (getComputedStyle(document.documentElement).getPropertyValue("--radius-card").trim() === "8px" ? "Loaded" : "Missing")), + ]); +} + +/** The bar's item and its panel, filled with the demo's own elements. */ +function fillStatusBar(shell) { + const item = shell.statusBar.item("checks"); + const barLine = window.kit.ui.statusLine({ text: "Checking…", tone: "running" }); + const headLine = window.kit.ui.statusLine({ text: "Checking…", tone: "running" }); + headLine.element.classList.add("demo-panel-head"); + item.button.append(barLine.element); + + const facts = document.createElement("dl"); + facts.className = "demo-facts"; + const again = document.createElement("button"); + again.type = "button"; + again.className = "btn btn-sm btn-outline-secondary"; + again.textContent = "Check Again"; + const overview = document.createElement("button"); + overview.type = "button"; + overview.className = "btn btn-sm btn-outline-secondary"; + overview.textContent = "Show Overview"; + const actions = document.createElement("div"); + actions.className = "demo-panel-actions"; + actions.append(again, overview); + item.panel.append(headLine.element, facts, actions); + + /** Draw the item and its panel from the Overview's rows. */ + function draw() { + const failed = CHECKS.filter(([, id]) => FAILED.test($(id).textContent)).length; + const state = failed === 0 + ? { text: "All Checks Passed", tone: "success", title: "Every check on the Overview passed." } + : { text: failed === 1 ? "1 Check Failed" : `${failed} Checks Failed`, tone: "danger", title: "The Overview says which." }; + barLine.set(state); + headLine.set(state); + facts.replaceChildren(...CHECKS.flatMap(([name, id]) => { + const term = document.createElement("dt"); + term.textContent = name; + const value = document.createElement("dd"); + // A bridge that's there is "Present": its version changes with each update of the demo's Electron. + const result = $(id).textContent; + value.textContent = !FAILED.test(result) && id.endsWith("-api") ? "Present" : result; + return [term, value]; + })); + } + + again.addEventListener("click", () => { + const restore = window.kit.ui.busy(again, "Checking…"); + barLine.set({ text: "Checking…", tone: "running" }); + headLine.set({ text: "Checking…", tone: "running" }); + // A moment, as a real check would take. + setTimeout(() => runChecks().then(() => { + restore(); + draw(); + }), 700); + }); + overview.addEventListener("click", () => { + item.close(); + shell.showView("overview"); + }); + return draw; +} + // The files the demo is opened with, kept for the tests (it shows none). window.filesOpened = []; window.kitAPI?.onFilesOpened((paths) => window.filesOpened.push(paths)); @@ -270,18 +358,18 @@ async function init() { credits: { logo: "assets/logo.svg" }, // What the demo is busy with, asked before Restart Now: never anything, unless a test says so. busy: () => window.demoBusy ?? null, + statusBar: { items: [{ id: "checks", label: "Checks", live: true, panel: { label: "Check Details", width: 360 } }] }, }); + // For the tests, which show the bar. + window.demoShell = shell; + const drawChecks = fillStatusBar(shell); await Promise.all([ shell.ready, window.kitAPI.getSettings().then(applySettings), - show("kit-api", async () => (window.kitAPI ? `App version ${await window.kitAPI.getVersion()}` : "Missing")), - show("electron-api", async () => (window.electronAPI ? `Electron ${await window.electronAPI.getElectronVersion()}` : "Missing")), - show("sandboxed", () => (window.electronAPI?.sandboxed ? "Yes" : "No")), - show("kit-js", () => (window.kit ? "Loaded" : "Missing")), - // A token only kit.css sets. - show("kit-css", () => (getComputedStyle(document.documentElement).getPropertyValue("--radius-card").trim() === "8px" ? "Loaded" : "Missing")), + runChecks(), ]); + drawChecks(); $("open-isolated").addEventListener("click", () => window.electronAPI.openIsolatedWindow()); document.body.dataset.ready = "true"; diff --git a/demo/src/styles.css b/demo/src/styles.css index 83907e6..0783ab6 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -52,3 +52,40 @@ gap: 1rem; max-width: 20rem; } + +/* The bar's panel: its head line, each check's result (a term and its value, + side by side), then its buttons on one line. */ +.demo-panel-head { + font-size: 0.85rem; + font-weight: 500; +} + +.demo-facts { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.35rem 0.85rem; + margin: 0; + font-size: 0.8rem; +} + +.demo-facts dt { + font-weight: 400; + color: var(--bs-secondary-color); +} + +.demo-facts dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.demo-panel-actions { + display: flex; + gap: 0.4rem; +} + +.demo-panel-actions .btn { + --bs-btn-padding-y: 0.15rem; + --bs-btn-padding-x: 0.55rem; + --bs-btn-font-size: 0.78rem; +} diff --git a/docs/README.md b/docs/README.md index a9eb6ba..99f48a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,8 +12,8 @@ and links here, so these can be corrected without a new release. |---|---| | [Getting Started](getting-started.md) | What the package exports, `start()` at the top of `main.js`, and the stylesheets and scripts a page loads | | [IPC](ipc.md) | The shared channels behind `window.kitAPI`, links and the allowlist, and `kit.ipc.handle()` for an app's own, each answering the app's own page only | -| [The Shell, Settings and Credits](shell.md) | `kit.ui.mountShell()`: the nav rail, the header, and the Settings view with its Update and Credits tabs | -| [Page Components](page-components.md) | Toasts, prompts, the parts of a section of files (drop zone, progress, action bar), cards and busy buttons, the switch and the stepped slider, the key guard, `kit.format`, and the copy rules | +| [The Shell, Settings and Credits](shell.md) | `kit.ui.mountShell()`: the nav rail, the header, the bottom bar and its panels, and the Settings view with its Update and Credits tabs | +| [Page Components](page-components.md) | Toasts, prompts, the parts of a section of files (drop zone, progress, action bar), cards, the status line and busy buttons, the switch and the stepped slider, the key guard, `kit.format`, and the copy rules | | [The Main Process](main-process.md) | The main window, isolated sessions, spell checking, one instance and the files an app is opened with, the settings and their migration, the redacted log, and the menu | | [Updates](updates.md) | The updater: when it checks, Stable, Beta and Alpha, automatic downloads, and its state | | [Building](building.md) | `config()`, the electron-builder config every app builds with; the files an app opens, on Linux; the update files every release carries; and naming an app's files | diff --git a/docs/development.md b/docs/development.md index 529737a..3e97af8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -40,7 +40,7 @@ package that's already on npm. ## Visual Tests `e2e/visual.spec.js` compares the demo's window with committed images, in -`e2e/visual.spec.js-snapshots/`, using Playwright's `toHaveScreenshot()`. It takes 62 images: +`e2e/visual.spec.js-snapshots/`, using Playwright's `toHaveScreenshot()`. It takes 78 images: | State | Accents | Themes | |---|---|---| @@ -56,6 +56,8 @@ package that's already on npm. | The Queue on the collapsed rail: its bulk bar over its cards, two to a row, a paused upload's Resume focused by keyboard | the demo's and each app's | light and dark | | The Queue with three cards selected: the bar's box half ticked, "3 selected", its target's note and the actions a selection shows | the demo's and each app's | light and dark | | The update dot on the collapsed rail | the demo's | light and dark | +| The bottom bar shown under Controls, its item focused by keyboard | the demo's and each app's | light and dark | +| The bottom bar with its panel open, by a click | the demo's and each app's | light and dark | The app accents are the ones in `test/fixtures/accents/`. The page is 760 × 600 at a scale factor of 1, drawn without the GPU, as on the runner, which has none. Motion is reduced, so every transition ends at once. The caret is hidden and the mouse is parked. The diff --git a/docs/page-components.md b/docs/page-components.md index 88fbea9..10f910f 100644 --- a/docs/page-components.md +++ b/docs/page-components.md @@ -1,6 +1,6 @@ # Page Components -The page library's parts: toasts, prompts, the parts of a section of files, cards, the card grid and its bulk bar, busy buttons, the switch, the stepped slider, the key guard, the house's wording for numbers, and its copy rules. +The page library's parts: toasts, prompts, the parts of a section of files, cards and the status line, the card grid and its bulk bar, busy buttons, the switch, the stepped slider, the key guard, the house's wording for numbers, and its copy rules. [All the docs](README.md) @@ -204,6 +204,24 @@ onClick: (button) => { } ``` +## The Status Line + +`kit.ui.statusLine({ text, tone, title })` is a card's status line on its own, for a state shown outside a card: a +Settings tab's, a bottom bar item's. A card builds its own line through it, so the two never drift. + +```js +const line = kit.ui.statusLine({ text: "Connected", tone: "success", title: "Connected, through whichever adapter Windows chooses." }); +pane.append(line.element); +line.set({ text: "Checking…", tone: "running" }); // the whole of it each time; the title is the text again +``` + +- `element` is a `span.kit-card-status.kit-tone-` holding `.kit-status-dot` and `.kit-status-text`, so it can + sit in a button. The text is one line, cut off with an ellipsis; `title` is its tooltip, the text unless given. +- **The tones** are a card's: `ready`, `running` (pulsing; still under reduced motion), `warning`, `danger`, + `success` and `muted`, as under Cards above. The words follow the copy rules below. +- A tone that isn't one of them, text that isn't text, or an option it doesn't take throws, from `set()` too, + which leaves the line as it was. + ## The Card Grid `kit.ui.cardGrid(container, { view, onSelect })` lays out a list's cards and selects them, by the rules File diff --git a/docs/shell.md b/docs/shell.md index 637ea86..c9baf56 100644 --- a/docs/shell.md +++ b/docs/shell.md @@ -1,6 +1,6 @@ # The Shell, Settings and Credits -`kit.ui.mountShell()`: the nav rail, the header, and the Settings view with its Update and Credits tabs. +`kit.ui.mountShell()`: the nav rail, the header, the bottom bar, and the Settings view with its Update and Credits tabs. [All the docs](README.md) @@ -41,6 +41,55 @@ shell.showView("convert"); // or shell.showSettings( doesn't move), and out of the Tab order there. - The shared markup is built with `createElement` and `textContent`, never HTML strings. +## The Bottom Bar + +With `statusBar`, the shell adds a slim bar along the window's bottom, under the rail and the sections, for what +the app shows at all times, such as its connection. The kit builds the bar, each item's button and its panel, and +wires them; the app fills each item and its panel with its own elements: + +```js +const shell = kit.ui.mountShell({ + // …the shell's other options, as above + statusBar: { + items: [{ id: "connection", label: "Connection", live: true, panel: { label: "Connection Details", width: 440 } }], + }, +}); +const connection = shell.statusBar.item("connection"); // { button, panel, open(), close(), opened } +const line = kit.ui.statusLine({ text: "Connected", tone: "success", title: "Connected, through whichever adapter Windows chooses." }); +connection.button.append(line.element); // the item: a status line, then anything else +connection.panel.append(details); // the panel: the app's own elements +shell.statusBar.show(settings.showConnection); // hidden until shown +``` + +``` +.app-frame.kit-with-statusbar the window less the bar +footer.kit-statusbar 26px, a line along its top +├── button.kit-statusbar-item an item, filled by the app +└── .kit-statusbar-panel[role=dialog] its panel, Close at its top right, then .panel +``` + +- **Hidden until `show(true)`**; `show(false)` hides it again, closing any open panel. While it shows, the frame takes + the rest of the window, so nothing scrolls; without `statusBar`, nothing about the frame changes and + `shell.statusBar` is `null`. +- **The bar** is 26px, in Bootstrap's tertiary background with a line along its top, its text 0.75rem. +- **Each item** is a borderless button, washed in the accent when hovered and while its panel is open, with + `aria-haspopup="dialog"`, `aria-expanded` and `aria-controls`. It's named by its `label`, which isn't drawn, then + what the app put in it ("Connection Connected"), so the label says what the item is and the content its state. + A status line's `title` is its tooltip: say what the colour means there. +- **`live: true`** makes the item a polite live region, its new content read out as it changes. Without it nothing in + the bar is announced: ask for it only where a change is news to someone who can't see the bar. +- **The panel** is a non-modal `role="dialog"` named by `panel.label`, `panel.width` px wide (capped to the window, + 8px from its sides), 6px above its item, with the kit's Close at its top right (the app's first element leaves + room for it). It opens on a click, Enter or Space and takes the focus, so a screen reader reads its name and Tab + reaches its controls. **Escape, Close, or Tab past its last control (Shift+Tab before its first) close it, and the + focus goes back to its item;** a click outside closes it and leaves the focus where the click put it. It's never + left open behind the focus. One panel is open at a time. +- **`open()`** opens it as a click does (refused while the bar is hidden); **`close()`** closes it, the focus going + back to the item if it was in the panel, so a panel's own button (Network Settings) can close it and move on; + **`opened`** says whether it's open. +- A mistake in the options, an `id` that isn't an item, or `show()` given anything but `true` or `false` throws, as + every `kit.ui` helper does. Each `id` is a lowercase name; the button's `id` is `kit-statusbar-`. + ## Settings and Credits The Settings view has tabs across the top (`role="tablist"`; the arrow keys, Home and End move between diff --git a/e2e/accessibility.spec.js b/e2e/accessibility.spec.js index 84abd92..acd780e 100644 --- a/e2e/accessibility.spec.js +++ b/e2e/accessibility.spec.js @@ -4,7 +4,8 @@ // 2.2 A and AA: every view and every Settings tab, with the rail expanded and // collapsed, in the light and dark themes, with the demo's own accent and // with each of the three apps' accents (test/fixtures/accents/), laid over the -// demo's in turn. Each theme is switched as the OS switches it (nativeTheme, +// demo's in turn; and the bottom bar, shown, and with its panel open. Each +// theme is switched as the OS switches it (nativeTheme, // from main), and the kit's theme.js draws the page in it. // // axe normally finishes its run in a blank page it opens beside the one it @@ -77,6 +78,30 @@ for (const name of APP_ACCENTS) { }); } +test("axe finds nothing in the bottom bar, or with its panel open, in both themes, in the demo's accent and each app's", async ({ demo }) => { + const main = await demo.mainWindow(); + await main.evaluate(() => window.demoShell.statusBar.show(true)); + const item = main.locator("#kit-statusbar-checks"); + const found = []; + for (const name of [null, ...APP_ACCENTS]) { + const style = name && await main.addStyleTag({ content: fs.readFileSync(path.join(__dirname, "..", "test", "fixtures", "accents", `${name}.css`), "utf8") }); + for (const theme of ["light", "dark"]) { + await demo.useTheme(main, theme); + for (const open of [false, true]) { + if (open) await item.click(); + await main.mouse.move(0, 0); + await main.evaluate(() => Promise.all(document.getAnimations().filter((a) => a instanceof CSSTransition).map((a) => a.finished))); + const place = `${name ?? "the demo"}'s accent, the ${theme} theme, the bar${open ? " with its panel open" : ""}`; + found.push(...(await violations(main)).map((line) => `${place}: ${line}`)); + } + await main.keyboard.press("Escape"); + await expect(main.getByRole("dialog", { name: "Check Details" })).toBeHidden(); + } + await style?.evaluate((element) => element.remove()); + } + expect(found).toEqual([]); +}); + test("axe catches an accent that fails AA, so a clean run means something", async ({ demo }) => { const main = await demo.mainWindow(); // File Converter's accent before the kit: white on #28a745 is 3.13:1, and its green as text 3.13:1. diff --git a/e2e/keyboard.spec.js b/e2e/keyboard.spec.js index cc2ef1a..3bb4ac6 100644 --- a/e2e/keyboard.spec.js +++ b/e2e/keyboard.spec.js @@ -4,8 +4,10 @@ // control in a sensible order, and each one shows a focus ring that's solid, // 2px, and 3:1 or more against what's behind it (01 §4), in both themes, // with the rail expanded and collapsed, in the demo's accent and each app's. -// Nothing here opens a modal or popover: a prompt's keyboard (the focus kept -// inside, Escape, the focus going back) is in prompts.spec.js. +// Nothing here opens a modal: a prompt's keyboard (the focus kept inside, +// Escape, the focus going back) is in prompts.spec.js. The bottom bar's panel +// is opened only to walk its controls' rings; its keyboard is in +// status-bar.spec.js. const fs = require("fs"); const path = require("path"); @@ -108,6 +110,30 @@ test("every focus ring holds 3:1 in both themes, the rail expanded and collapsed expect(problems).toEqual([]); }); +test("the bottom bar's item comes after the view, and it and its panel's controls hold 3:1 in both themes, in every accent", async ({ demo }) => { + const main = await demo.mainWindow(); + await main.evaluate(() => window.demoShell.statusBar.show(true)); + const problems = []; + for (const name of [null, ...APP_ACCENTS]) { + const style = name && await main.addStyleTag({ content: fs.readFileSync(path.join(__dirname, "..", "test", "fixtures", "accents", `${name}.css`), "utf8") }); + for (const theme of ["light", "dark"]) { + await demo.useTheme(main, theme); + await main.getByRole("button", { name: "Primary Action" }).focus(); + const [item] = await walk(main, 1); + expect(item.name).toBe("Checks All Checks Passed"); + await main.keyboard.press("Enter"); + await expect(main.getByRole("dialog", { name: "Check Details" })).toBeFocused(); + const inPanel = await walk(main, 3); + expect(inPanel.map((stop) => stop.name)).toEqual(["Close", "Check Again", "Show Overview"]); + const where = `${name ?? "the demo"}'s accent, ${theme}`; + problems.push(...ringProblems([item, ...inPanel]).map((line) => `${where}: ${line}`)); + await main.keyboard.press("Escape"); + } + await style?.evaluate((element) => element.remove()); + } + expect(problems).toEqual([]); +}); + test("a check that catches a faint ring, so a clean walk means something", async ({ demo }) => { const main = await demo.mainWindow(); // Bootstrap's own ring: its blue at a quarter, as a glow. diff --git a/e2e/status-bar.spec.js b/e2e/status-bar.spec.js new file mode 100644 index 0000000..c03f44f --- /dev/null +++ b/e2e/status-bar.spec.js @@ -0,0 +1,488 @@ +"use strict"; + +// The bottom bar kit.ui.mountShell({ statusBar }) adds, and kit.ui.statusLine(), +// in real Electron: the bar hidden until shown, then under the rail and the +// sections with nothing scrolling; its item's name and expanded state; its +// panel opened by a click, Enter and Space, taking the focus, and each way out +// (Escape, Close, Tab past its last control, Shift+Tab before its first, a +// click outside, the app's close()) with the focus going back to the item, or +// staying where a click put it; one panel at a time; an item read out as it +// changes only when it asks; the options' refusals; and the status line's +// tones and refusals. The demo's bar has one item (Checks); a test needing +// other items mounts a shell of its own in the demo's page in place of the +// demo's. axe on the bar and its panel is in accessibility.spec.js, their +// focus rings in keyboard.spec.js, and their look in visual.spec.js. + +const { test, expect } = require("./helpers/demo"); + +/** The demo's window, with its bar shown. */ +async function withBar(demo) { + const main = await demo.mainWindow(); + await main.evaluate(() => window.demoShell.statusBar.show(true)); + return main; +} + +/** + * Put a shell of the test's own in place of the demo's: one section, and the + * bar's items given, each filled with its own text and, in its panel, two + * buttons. The bar is shown; the shell is window.remounted. + */ +const remount = (page, items, { show = true } = {}) => page.evaluate(({ items, show }) => { + document.querySelector(".app-frame").remove(); + document.querySelector(".kit-statusbar")?.remove(); + const section = document.createElement("div"); + section.textContent = "A section of the test's own."; + const shell = window.kit.ui.mountShell({ + title: "A Test App", + sections: [{ view: "only", label: "Only", icon: "dashboard", element: section }], + ...(items ? { statusBar: { items } } : {}), + }); + window.remounted = shell; + for (const { id } of items ?? []) { + const item = shell.statusBar.item(id); + item.button.textContent = `${id} is fine`; + for (const label of ["First", "Last"]) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = `${label} of ${id}`; + item.panel.append(button); + } + } + if (items && show) shell.statusBar.show(true); +}, { items, show }); + +/** The error a call in the page throws, or null. */ +const thrown = (page, fn, arg) => page.evaluate(([source, arg]) => { + try { + // eslint-disable-next-line no-new-func + new Function("arg", source)(arg); + return null; + } catch (err) { + return err.message; + } +}, [fn, arg]); + +/** What has the focus: its id, or its tag. */ +const focusedId = (page) => page.evaluate(() => document.activeElement.id || document.activeElement.tagName.toLowerCase()); + +test("the bar is hidden until shown, then sits under the rail and the sections, 26px, with nothing scrolling", async ({ demo }) => { + const main = await demo.mainWindow(); + const bar = main.locator("footer.kit-statusbar"); + await expect(bar).toBeAttached(); + await expect(bar).toBeHidden(); + const frameHeight = () => main.locator(".app-frame").evaluate((frame) => frame.getBoundingClientRect().height); + expect(await frameHeight()).toBe(await main.evaluate(() => innerHeight)); + + await main.evaluate(() => window.demoShell.statusBar.show(true)); + await expect(bar).toBeVisible(); + const layout = await main.evaluate(() => { + const box = (selector) => document.querySelector(selector).getBoundingClientRect(); + const style = getComputedStyle(document.querySelector(".kit-statusbar")); + return { + window: [innerWidth, innerHeight], + bar: box(".kit-statusbar").toJSON(), + rail: box(".nav-rail").bottom, + column: box(".app-shell").bottom, + frame: box(".app-frame").height, + scrolls: [document.scrollingElement.scrollHeight, document.scrollingElement.scrollWidth], + follows: document.querySelector(".app-frame").nextElementSibling === document.querySelector(".kit-statusbar"), + border: [style.borderTopWidth, style.borderTopStyle, style.borderTopColor, style.borderBottomWidth], + background: style.backgroundColor, + tertiary: (() => { + const probe = document.createElement("div"); + probe.style.backgroundColor = "var(--bs-tertiary-bg)"; + document.body.append(probe); + const colour = getComputedStyle(probe).backgroundColor; + probe.remove(); + return colour; + })(), + borderColour: getComputedStyle(document.querySelector(".kit-card") ?? document.body).getPropertyValue("--bs-border-color").trim(), + fontSize: style.fontSize, + }; + }); + const [width, height] = layout.window; + // Across the whole window at its bottom, under the rail as well as the column, which end where it starts. + expect(layout.bar).toMatchObject({ x: 0, width, height: 26, bottom: height }); + expect(layout.rail).toBe(layout.bar.top); + expect(layout.column).toBe(layout.bar.top); + expect(layout.frame).toBe(height - 26); + expect(layout.scrolls).toEqual([height, width]); + expect(layout.follows).toBe(true); + expect(layout.border.slice(0, 2)).toEqual(["1px", "solid"]); + expect(layout.border[3]).toBe("0px"); + expect(layout.background).toBe(layout.tertiary); + expect(layout.fontSize).toBe("12px"); + + // Hidden again, the frame takes the whole window once more. + await main.evaluate(() => window.demoShell.statusBar.show(false)); + await expect(bar).toBeHidden(); + expect(await frameHeight()).toBe(height); +}); + +test("without statusBar there's no bar, and the frame is as it was", async ({ demo }) => { + const main = await demo.mainWindow(); + await remount(main, null); + await expect(main.locator(".kit-statusbar")).toHaveCount(0); + expect(await main.evaluate(() => window.remounted.statusBar)).toBeNull(); + expect(await main.locator(".app-frame").evaluate((frame) => [frame.getBoundingClientRect().height === innerHeight, frame.className])).toEqual([true, "app-frame"]); +}); + +test("an item is a button named by its label and its content, with aria-haspopup, aria-expanded and aria-controls", async ({ demo }) => { + const main = await withBar(demo); + // The wash read at rest, not partway in. + await main.emulateMedia({ reducedMotion: "reduce" }); + const item = main.getByRole("button", { name: "Checks All Checks Passed", exact: true }); + await expect(item).toBeVisible(); + await expect(item).toHaveAttribute("aria-haspopup", "dialog"); + await expect(item).toHaveAttribute("aria-expanded", "false"); + const panelId = await item.getAttribute("aria-controls"); + const panel = main.locator(`[id="${panelId}"]`); + await expect(panel).toHaveAttribute("role", "dialog"); + await expect(panel).toBeHidden(); + // Its label is in the page only for its name. + await expect(main.getByText("Checks", { exact: true })).toBeHidden(); + + await item.click(); + await expect(item).toHaveAttribute("aria-expanded", "true"); + await expect(main.getByRole("dialog", { name: "Check Details" })).toBeVisible(); + // Washed while open, the pointer gone from it. + await main.mouse.move(400, 12); + // Its status line's title says what its colour means. + await expect(item.locator(".kit-status-text")).toHaveAttribute("title", "Every check on the Overview passed."); + // Borderless, and washed in the accent while it's open. + const look = await item.evaluate((button) => { + const style = getComputedStyle(button); + return { border: style.borderTopWidth, background: style.backgroundColor, height: button.getBoundingClientRect().height }; + }); + expect(look.border).toBe("0px"); + expect(look.background).toMatch(/^rgba\(13, 110, 253, 0\.04\)$/); + expect(look.height).toBe(22); + await item.click(); + await expect(item).toHaveAttribute("aria-expanded", "false"); +}); + +test("the panel opens on a click, Enter or Space, over its item, takes the focus, and holds the app's own elements beside Close", async ({ demo }) => { + const main = await withBar(demo); + const item = main.locator("#kit-statusbar-checks"); + const panel = main.getByRole("dialog", { name: "Check Details" }); + for (const how of ["click", "Enter", "Space"]) { + if (how === "click") { + await item.click(); + } else { + await item.focus(); + await main.keyboard.press(how); + } + await expect(panel, how).toBeVisible(); + await expect(panel, how).toBeFocused(); + await main.keyboard.press("Escape"); + await expect(panel, how).toBeHidden(); + } + + await item.click(); + const place = await main.evaluate(() => { + const panel = document.querySelector("#kit-statusbar-checks-panel").getBoundingClientRect(); + const bar = document.querySelector(".kit-statusbar").getBoundingClientRect(); + return { left: panel.left, width: panel.width, gap: bar.top - panel.bottom }; + }); + // 360px, as the demo asks; 6px over the bar, its left edge 8px from the window's. + expect(place).toEqual({ left: 8, width: 360, gap: 6 }); + // The kit's Close first, at its top right, then the demo's own. + expect(await panel.getByRole("button").allTextContents()).toEqual(["close", "Check Again", "Show Overview"]); + await expect(panel.getByRole("button", { name: "Close", exact: true })).toHaveAttribute("title", "Close"); + await expect(panel.getByRole("term")).toHaveText(["Shared Bridge", "App Bridge", "Sandboxed", "Page Library", "Kit Styles"]); + const close = await panel.getByRole("button", { name: "Close", exact: true }).boundingBox(); + const box = await panel.boundingBox(); + expect(box.x + box.width - (close.x + close.width)).toBeGreaterThan(10); + expect(close.y - box.y).toBeLessThan(16); +}); + +test("a panel wider than the window is capped to it", async ({ demo }) => { + const main = await demo.mainWindow(); + await remount(main, [{ id: "wide", label: "Wide", panel: { label: "Wide Details", width: 5000 } }]); + await main.getByRole("button", { name: "Wide wide is fine" }).click(); + const [width, left] = await main.getByRole("dialog", { name: "Wide Details" }).evaluate((panel) => [panel.getBoundingClientRect().width, panel.getBoundingClientRect().left]); + expect(width).toBe(await main.evaluate(() => innerWidth - 16)); + expect(left).toBe(8); +}); + +test("Escape, Close and Tab past the last control close the panel, the focus back on its item; so does Shift+Tab before the first", async ({ demo }) => { + const main = await withBar(demo); + const item = main.locator("#kit-statusbar-checks"); + const panel = main.getByRole("dialog", { name: "Check Details" }); + const ways = { + Escape: () => main.keyboard.press("Escape"), + Close: () => panel.getByRole("button", { name: "Close", exact: true }).click(), + "Close by keyboard": async () => { + await main.keyboard.press("Tab"); + await expect(panel.getByRole("button", { name: "Close", exact: true })).toBeFocused(); + await main.keyboard.press("Enter"); + }, + "Tab past the last": async () => { + // Close, Check Again, Show Overview, then out. + for (const name of ["Close", "Check Again", "Show Overview"]) { + await main.keyboard.press("Tab"); + await expect(panel.getByRole("button", { name, exact: true })).toBeFocused(); + } + await main.keyboard.press("Tab"); + }, + "Shift+Tab before the first": async () => { + await main.keyboard.press("Tab"); + await main.keyboard.press("Shift+Tab"); + }, + "Shift+Tab from the panel": () => main.keyboard.press("Shift+Tab"), + }; + for (const [way, leave] of Object.entries(ways)) { + await item.focus(); + await main.keyboard.press("Enter"); + await expect(panel, way).toBeFocused(); + await leave(); + await expect(panel, way).toBeHidden(); + await expect(item, way).toBeFocused(); + await expect(item, way).toHaveAttribute("aria-expanded", "false"); + } +}); + +test("a click outside closes the panel and leaves the focus where it put it; a click inside doesn't close it", async ({ demo }) => { + const main = await withBar(demo); + const item = main.locator("#kit-statusbar-checks"); + const panel = main.getByRole("dialog", { name: "Check Details" }); + + // Inside, on its text: it stays open, the focus in it. + await item.click(); + await panel.getByRole("term").first().click(); + await expect(panel).toBeVisible(); + expect(await panel.evaluate((p) => p.contains(document.activeElement))).toBe(true); + + // Outside, on the page's text: closed, and the focus not sent back to the item. + await main.getByRole("heading", { name: "Accent" }).click(); + await expect(panel).toBeHidden(); + expect(await focusedId(main)).toBe("body"); + + // Outside, on another control: closed, the focus on that control. + await item.click(); + await main.getByRole("button", { name: "Controls", exact: true }).click(); + await expect(panel).toBeHidden(); + await expect(main.getByRole("button", { name: "Controls", exact: true })).toBeFocused(); + + // Tabbing to the page's own controls while it's open (a focus moved by the app): closed behind it. + await item.click(); + await main.getByRole("button", { name: "Overview", exact: true }).focus(); + await expect(panel).toBeHidden(); +}); + +test("the app's own buttons work in the panel: Check Again checks, read out as it changes, and Show Overview closes it", async ({ demo }) => { + const main = await withBar(demo); + const item = main.locator("#kit-statusbar-checks"); + const panel = main.getByRole("dialog", { name: "Check Details" }); + await main.getByRole("button", { name: "Controls", exact: true }).click(); + await item.click(); + await panel.getByRole("button", { name: "Check Again" }).click(); + await expect(item).toHaveAccessibleName("Checks Checking…"); + await expect(panel.getByRole("button", { name: "Checking…" })).toBeDisabled(); + await expect(item).toHaveAccessibleName("Checks All Checks Passed"); + await expect(panel).toBeVisible(); + + // The app's close(), with the focus in the panel: it goes back to the item before the app moves on. + await panel.getByRole("button", { name: "Show Overview" }).click(); + await expect(panel).toBeHidden(); + await expect(item).toBeFocused(); + await expect(main.locator("#overview-view")).toBeVisible(); + expect(await main.evaluate(() => window.demoShell.statusBar.item("checks").opened)).toBe(false); +}); + +test("open(), close() and opened, from the app; show(false) closes an open panel; one panel is open at a time", async ({ demo }) => { + const main = await demo.mainWindow(); + await remount(main, [ + { id: "first", label: "First", panel: { label: "First Details", width: 300 } }, + { id: "second", label: "Second", panel: { label: "Second Details", width: 300 } }, + ]); + const first = main.getByRole("dialog", { name: "First Details" }); + const second = main.getByRole("dialog", { name: "Second Details" }); + const opened = () => main.evaluate(() => ["first", "second"].map((id) => window.remounted.statusBar.item(id).opened)); + + await main.evaluate(() => window.remounted.statusBar.item("first").open()); + await expect(first).toBeFocused(); + expect(await opened()).toEqual([true, false]); + // The second, by a click on its item: the first closes as it opens. + await main.getByRole("button", { name: "Second second is fine" }).click(); + await expect(second).toBeFocused(); + await expect(first).toBeHidden(); + expect(await opened()).toEqual([false, true]); + await expect(main.locator(".kit-statusbar-item[aria-expanded=true]")).toHaveCount(1); + // The second's panel sits over its own item. + const [panelLeft, itemLeft] = await main.evaluate(() => ["#kit-statusbar-second-panel", "#kit-statusbar-second"].map((s) => document.querySelector(s).getBoundingClientRect().left)); + expect(panelLeft).toBe(itemLeft); + + // The app's close(): closed, the focus back on the item it was in. + await main.evaluate(() => window.remounted.statusBar.item("second").close()); + await expect(second).toBeHidden(); + await expect(main.locator("#kit-statusbar-second")).toBeFocused(); + expect(await opened()).toEqual([false, false]); + + // Hiding the bar closes what's open. + await main.evaluate(() => window.remounted.statusBar.item("first").open()); + await main.evaluate(() => window.remounted.statusBar.show(false)); + await expect(first).toBeHidden(); + expect(await opened()).toEqual([false, false]); + await expect(main.locator("#kit-statusbar-first")).toHaveAttribute("aria-expanded", "false"); +}); + +test("an item is read out as it changes only when it asks (live), and the bar itself never is", async ({ demo }) => { + const main = await withBar(demo); + const demoItem = main.locator("#kit-statusbar-checks"); + await expect(demoItem).toHaveAttribute("aria-live", "polite"); + await expect(demoItem).toHaveAttribute("aria-atomic", "true"); + + await remount(main, [ + { id: "quiet", label: "Quiet", panel: { label: "Quiet Details", width: 300 } }, + { id: "loud", label: "Loud", live: true, panel: { label: "Loud Details", width: 300 } }, + { id: "said", label: "Said", live: false, panel: { label: "Said Details", width: 300 } }, + ]); + const live = await main.evaluate(() => ({ + bar: [...document.querySelector(".kit-statusbar").attributes].map((a) => a.name), + items: [...document.querySelectorAll(".kit-statusbar-item")].map((b) => [b.id, b.getAttribute("aria-live"), b.getAttribute("aria-atomic")]), + regions: document.querySelectorAll(".kit-statusbar [aria-live], .kit-statusbar [role=status], .kit-statusbar [role=log]").length, + })); + expect(live.bar).toEqual(["class"]); + expect(live.items).toEqual([ + ["kit-statusbar-quiet", null, null], + ["kit-statusbar-loud", "polite", "true"], + ["kit-statusbar-said", null, null], + ]); + expect(live.regions).toBe(1); +}); + +test("the bar's options, show(), item() and open() are checked, each refused with what it must be", async ({ demo }) => { + const main = await demo.mainWindow(); + const panel = { label: "Details", width: 300 }; + const item = { id: "net", label: "Connection", panel }; + const refusals = [ + [null, "statusBar must be { items }."], + [[], "statusBar must be { items }."], + [{ items: [item], size: 26 }, 'statusBar has no option "size".'], + [{}, "statusBar.items must be a list of one or more."], + [{ items: [] }, "statusBar.items must be a list of one or more."], + [{ items: [null] }, "statusBar.items[0] must be { id, label, live, panel }."], + [{ items: [{ ...item, element: "x" }] }, 'statusBar.items[0] has no option "element".'], + [{ items: [{ ...item, id: "Net" }] }, "statusBar.items[0].id must be a lowercase name."], + [{ items: [{ ...item, id: undefined }] }, "statusBar.items[0].id must be a lowercase name."], + [{ items: [item, item] }, 'statusBar.items[1].id "net" is taken.'], + [{ items: [{ ...item, label: "" }] }, "statusBar.items[0].label must be its name."], + [{ items: [{ ...item, live: "yes" }] }, "statusBar.items[0].live must be true or false."], + [{ items: [{ id: "net", label: "Connection" }] }, "statusBar.items[0].panel must be { label, width }."], + [{ items: [{ ...item, panel: { ...panel, height: 1 } }] }, 'statusBar.items[0].panel has no option "height".'], + [{ items: [{ ...item, panel: { width: 300 } }] }, "statusBar.items[0].panel.label must be its name."], + [{ items: [{ ...item, panel: { label: "Details" } }] }, "statusBar.items[0].panel.width must be its width in pixels, a whole number."], + [{ items: [{ ...item, panel: { ...panel, width: 0 } }] }, "statusBar.items[0].panel.width must be its width in pixels, a whole number."], + [{ items: [{ ...item, panel: { ...panel, width: 300.5 } }] }, "statusBar.items[0].panel.width must be its width in pixels, a whole number."], + [{ items: [{ ...item, panel: { ...panel, width: "440px" } }] }, "statusBar.items[0].panel.width must be its width in pixels, a whole number."], + ]; + for (const [statusBar, message] of refusals) { + // Refused before anything is built, so the demo's own shell is still the only one. + const said = await thrown(main, "window.kit.ui.mountShell({ title: 'A Test App', statusBar: arg });", statusBar); + expect(said, JSON.stringify(statusBar)).toBe(`kit.ui.mountShell(): ${message}`); + } + await expect(main.locator(".app-frame")).toHaveCount(1); + await expect(main.locator(".kit-statusbar")).toHaveCount(1); + + const bar = "window.demoShell.statusBar"; + expect(await thrown(main, `${bar}.show("yes");`)).toBe("kit.ui.mountShell(): statusBar.show() takes true or false."); + expect(await thrown(main, `${bar}.show();`)).toBe("kit.ui.mountShell(): statusBar.show() takes true or false."); + expect(await thrown(main, `${bar}.item("network");`)).toBe('kit.ui.mountShell(): statusBar has no item "network".'); + // Hidden, nothing opens. + expect(await thrown(main, `${bar}.item("checks").open();`)).toBe('kit.ui.mountShell(): statusBar.item("checks").open(): the bar is hidden; show it first.'); + await expect(main.locator(".kit-statusbar-panel:visible")).toHaveCount(0); +}); + +test("kit.ui.statusLine() is the card's status line on its own: a span with its dot and text, in each tone, its title the text unless given", async ({ demo }) => { + const main = await demo.mainWindow(); + await main.emulateMedia({ reducedMotion: "no-preference" }); + const lines = await main.evaluate(() => { + const host = document.createElement("div"); + host.id = "lines"; + document.getElementById("overview-view").prepend(host); + return ["ready", "running", "warning", "danger", "success", "muted"].map((tone) => { + const line = window.kit.ui.statusLine({ text: `It's ${tone}`, tone }); + host.append(line.element); + const dot = line.element.querySelector(".kit-status-dot"); + const text = line.element.querySelector(".kit-status-text"); + return { + tag: line.element.tagName.toLowerCase(), + className: line.element.className, + parts: [...line.element.children].map((child) => `${child.tagName.toLowerCase()}.${child.className}`), + text: text.textContent, + title: text.title, + display: getComputedStyle(line.element).display, + pulse: getComputedStyle(dot).animationName, + textColour: getComputedStyle(text).color, + }; + }); + }); + for (const line of lines) { + const tone = line.className.replace("kit-card-status kit-tone-", ""); + expect(line).toMatchObject({ tag: "span", parts: ["span.kit-status-dot", "span.kit-status-text"], text: `It's ${tone}`, title: `It's ${tone}`, display: "flex" }); + expect(line.pulse, tone).toBe(tone === "running" ? "kit-status-pulse" : "none"); + } + expect(lines.map((l) => l.className)).toEqual(["ready", "running", "warning", "danger", "success", "muted"].map((t) => `kit-card-status kit-tone-${t}`)); + // Each tone's text in its own colour, as a card's. + expect(new Set(lines.map((l) => l.textColour)).size).toBeGreaterThanOrEqual(5); + + // set() takes the whole of it again: a title given, then left out (the text again). + const after = await main.evaluate(() => { + const line = window.kit.ui.statusLine({ text: "Connected", tone: "success", title: "Connected, through whichever adapter Windows chooses." }); + const read = () => [line.element.className, line.element.textContent, line.element.querySelector(".kit-status-text").title]; + const first = read(); + line.set({ text: "Checking…", tone: "running" }); + return [first, read()]; + }); + expect(after).toEqual([ + ["kit-card-status kit-tone-success", "Connected", "Connected, through whichever adapter Windows chooses."], + ["kit-card-status kit-tone-running", "Checking…", "Checking…"], + ]); + + // Under reduced motion, running is still. + await main.emulateMedia({ reducedMotion: "reduce" }); + expect(await main.locator("#lines .kit-tone-running .kit-status-dot").evaluate((dot) => getComputedStyle(dot).animationName)).toBe("none"); +}); + +test("kit.ui.statusLine() and its set() refuse what they can't draw, and a refused set() leaves the line as it was", async ({ demo }) => { + const main = await demo.mainWindow(); + const tones = "ready, running, warning, danger, success, muted"; + const refusals = [ + [undefined, "it takes { text, tone, title }."], + [null, "it takes { text, tone, title }."], + ["Ready", "it takes { text, tone, title }."], + [{ text: "Ready", tone: "ready", dot: true }, 'it has no option "dot".'], + [{ tone: "ready" }, "it: text must be text."], + [{ text: 3, tone: "ready" }, "it: text must be text."], + [{ text: "Ready" }, `it: tone must be one of ${tones}.`], + [{ text: "Ready", tone: "info" }, `it: tone must be one of ${tones}.`], + [{ text: "Ready", tone: "ready", title: 3 }, "it: title must be text, or left out."], + [{ text: "Ready", tone: "ready", title: null }, "it: title must be text, or left out."], + ]; + for (const [options, message] of refusals) { + expect(await thrown(main, "window.kit.ui.statusLine(arg);", options), JSON.stringify(options)).toBe(`kit.ui.statusLine(): ${message}`); + } + const kept = await main.evaluate(() => { + const line = window.kit.ui.statusLine({ text: "Ready", tone: "ready" }); + const said = []; + for (const state of [{ text: "Gone", tone: "lost" }, { text: "Gone" }, null, { text: "Gone", tone: "danger", colour: "red" }]) { + try { + line.set(state); + } catch (err) { + said.push(err.message); + } + } + return { said, className: line.element.className, text: line.element.textContent }; + }); + expect(kept).toEqual({ + said: [ + `kit.ui.statusLine(): set(): tone must be one of ${tones}.`, + `kit.ui.statusLine(): set(): tone must be one of ${tones}.`, + "kit.ui.statusLine(): set() takes { text, tone, title }.", + 'kit.ui.statusLine(): set() has no option "colour".', + ], + className: "kit-card-status kit-tone-ready", + text: "Ready", + }); +}); diff --git a/e2e/visual.spec.js b/e2e/visual.spec.js index bb13e32..0b6a219 100644 --- a/e2e/visual.spec.js +++ b/e2e/visual.spec.js @@ -5,7 +5,7 @@ // the images are taken on a Windows CI runner, so they show Segoe UI as most // people see it, and Linux runs every other test without them. // -// Nineteen states, each a whole window: +// Twenty-one states, each a whole window: // - Settings > Update with an update waiting (and the dot), downloading, ready // to install, restarting (Restart Now busy), checking (Check for Updates // busy), and after a check that failed; @@ -24,11 +24,13 @@ // finished upload with its link; and the top of a failed one); the Queue // with three cards selected (the bar's box half ticked, "3 selected", its // target's note, and the actions a selection shows); then the update dot -// on the collapsed rail. -// All nineteen in the demo's accent, in the light and dark themes. The four -// that show the most accent (the rail, Controls toggled, and the Queue at -// rest and selected) again in each of the three apps' accents -// (test/fixtures/accents/), in both themes. 62 images. +// on the collapsed rail; +// - the bottom bar (kit.ui.mountShell({ statusBar })), shown under Controls +// with its item focused by keyboard, and with its panel open. +// All twenty-one in the demo's accent, in the light and dark themes. The six +// that show the most accent (the rail, Controls toggled, the Queue at rest +// and selected, the bar and its panel) again in each of the three apps' +// accents (test/fixtures/accents/), in both themes. 78 images. // // So that an image only changes when the look does: the window's page is // 760 x 600 at a scale factor of 1, drawn without the GPU; motion is reduced, so every transition ends @@ -305,12 +307,29 @@ const STATES = { await expect(main.locator(".nav-rail .update-dot:visible")).toHaveCount(1); await blur(main); }, + "status-bar": async (main, demo) => { + // The bottom bar shown, under Controls and the expanded rail, its item focused by keyboard. The dot is gone. + await pushUpdate(demo, { state: "idle" }); + await expect(main.locator(".update-dot:visible")).toHaveCount(0); + const expand = main.getByRole("navigation", { name: "Sections" }).getByRole("button", { name: "Expand", exact: true }); + if (await expand.count()) await expand.click(); + await main.getByRole("button", { name: "Controls", exact: true }).click(); + await main.evaluate(() => window.demoShell.statusBar.show(true)); + await main.getByLabel("A text field").focus(); + await main.keyboard.press("Tab"); + await expect(main.locator("#kit-statusbar-checks")).toBeFocused(); + }, + "status-bar-panel": async (main) => { + // Its panel open, by a click: the item washed, the focus in the panel. + await main.locator("#kit-statusbar-checks").click(); + await expect(main.getByRole("dialog", { name: "Check Details" })).toBeFocused(); + }, }; /** Which states each accent is pictured in. */ const PLAN = [ [null, Object.keys(STATES)], - ...APP_ACCENTS.map((accent) => [accent, ["controls-toggled", "rail-expanded", "cards", "cards-selected"]]), + ...APP_ACCENTS.map((accent) => [accent, ["controls-toggled", "rail-expanded", "cards", "cards-selected", "status-bar", "status-bar-panel"]]), ]; for (const [accent, states] of PLAN) { diff --git a/e2e/visual.spec.js-snapshots/demo-dark-status-bar-panel-win32.png b/e2e/visual.spec.js-snapshots/demo-dark-status-bar-panel-win32.png new file mode 100644 index 0000000..77c463a Binary files /dev/null and b/e2e/visual.spec.js-snapshots/demo-dark-status-bar-panel-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-dark-status-bar-win32.png b/e2e/visual.spec.js-snapshots/demo-dark-status-bar-win32.png new file mode 100644 index 0000000..606cae2 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/demo-dark-status-bar-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-light-status-bar-panel-win32.png b/e2e/visual.spec.js-snapshots/demo-light-status-bar-panel-win32.png new file mode 100644 index 0000000..4978992 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/demo-light-status-bar-panel-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-light-status-bar-win32.png b/e2e/visual.spec.js-snapshots/demo-light-status-bar-win32.png new file mode 100644 index 0000000..61b8d1d Binary files /dev/null and b/e2e/visual.spec.js-snapshots/demo-light-status-bar-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-dark-status-bar-panel-win32.png b/e2e/visual.spec.js-snapshots/dropgate-dark-status-bar-panel-win32.png new file mode 100644 index 0000000..77c463a Binary files /dev/null and b/e2e/visual.spec.js-snapshots/dropgate-dark-status-bar-panel-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-dark-status-bar-win32.png b/e2e/visual.spec.js-snapshots/dropgate-dark-status-bar-win32.png new file mode 100644 index 0000000..606cae2 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/dropgate-dark-status-bar-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-light-status-bar-panel-win32.png b/e2e/visual.spec.js-snapshots/dropgate-light-status-bar-panel-win32.png new file mode 100644 index 0000000..4978992 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/dropgate-light-status-bar-panel-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-light-status-bar-win32.png b/e2e/visual.spec.js-snapshots/dropgate-light-status-bar-win32.png new file mode 100644 index 0000000..61b8d1d Binary files /dev/null and b/e2e/visual.spec.js-snapshots/dropgate-light-status-bar-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-dark-status-bar-panel-win32.png b/e2e/visual.spec.js-snapshots/file-converter-dark-status-bar-panel-win32.png new file mode 100644 index 0000000..90cdf3b Binary files /dev/null and b/e2e/visual.spec.js-snapshots/file-converter-dark-status-bar-panel-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-dark-status-bar-win32.png b/e2e/visual.spec.js-snapshots/file-converter-dark-status-bar-win32.png new file mode 100644 index 0000000..f0f7234 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/file-converter-dark-status-bar-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-light-status-bar-panel-win32.png b/e2e/visual.spec.js-snapshots/file-converter-light-status-bar-panel-win32.png new file mode 100644 index 0000000..e994694 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/file-converter-light-status-bar-panel-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-light-status-bar-win32.png b/e2e/visual.spec.js-snapshots/file-converter-light-status-bar-win32.png new file mode 100644 index 0000000..3fb11f3 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/file-converter-light-status-bar-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-dark-status-bar-panel-win32.png b/e2e/visual.spec.js-snapshots/media-player-dark-status-bar-panel-win32.png new file mode 100644 index 0000000..3f117be Binary files /dev/null and b/e2e/visual.spec.js-snapshots/media-player-dark-status-bar-panel-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-dark-status-bar-win32.png b/e2e/visual.spec.js-snapshots/media-player-dark-status-bar-win32.png new file mode 100644 index 0000000..c88797f Binary files /dev/null and b/e2e/visual.spec.js-snapshots/media-player-dark-status-bar-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-light-status-bar-panel-win32.png b/e2e/visual.spec.js-snapshots/media-player-light-status-bar-panel-win32.png new file mode 100644 index 0000000..37f864f Binary files /dev/null and b/e2e/visual.spec.js-snapshots/media-player-light-status-bar-panel-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-light-status-bar-win32.png b/e2e/visual.spec.js-snapshots/media-player-light-status-bar-win32.png new file mode 100644 index 0000000..48685e2 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/media-player-light-status-bar-win32.png differ diff --git a/page/kit.js b/page/kit.js index 50458a4..9f803b3 100644 --- a/page/kit.js +++ b/page/kit.js @@ -28,6 +28,11 @@ // The Update tab and the update dot follow the kit's updater (update:status). // Restart Now installs a downloaded update at once; when busy() gives a // reason, the kit asks first, with that reason, in its own prompt. +// With statusBar: { items: [{ id, label, live, panel: { label, width } }] }, +// it adds a bar along the window's bottom, under the rail and the sections, +// hidden until shell.statusBar.show(true): each item a button the app fills +// (shell.statusBar.item(id).button), opening a panel above it that the app +// fills too (.panel). kit.ui.statusLine() is a card's status line on its own. // // kit.ui.toast(message, { type, timeout, action }) shows a toast under the // header; its action shows a list under the message (what was skipped). @@ -98,7 +103,7 @@ const VIEW_NAME = /^[a-z][a-z0-9-]*$/; const KIT_TABS = ["update", "credits"]; - function checkOptions({ title, sections = [], toolbar, settingsTabs = [], onViewChange, busy }) { + function checkOptions({ title, sections = [], toolbar, settingsTabs = [], onViewChange, busy, statusBar }) { const fail = (message) => { throw new Error(`kit.ui.mountShell(): ${message}`); }; @@ -127,6 +132,38 @@ } if (onViewChange !== undefined && typeof onViewChange !== "function") fail("onViewChange must be a function."); if (busy !== undefined && typeof busy !== "function") fail("busy must be a function."); + if (statusBar !== undefined) checkStatusBar(statusBar, fail); + } + + /** What a bar item, and its panel, take. */ + const STATUS_ITEM = ["id", "label", "live", "panel"]; + const STATUS_PANEL = ["label", "width"]; + + function checkStatusBar(statusBar, fail) { + const extra = (given, known, where) => { + const key = Object.keys(given).find((name) => !known.includes(name)); + if (key !== undefined) fail(`${where} has no option "${key}".`); + }; + if (statusBar === null || typeof statusBar !== "object" || Array.isArray(statusBar)) fail("statusBar must be { items }."); + extra(statusBar, ["items"], "statusBar"); + const { items } = statusBar; + if (!Array.isArray(items) || items.length === 0) fail("statusBar.items must be a list of one or more."); + const ids = new Set(); + for (const [i, item] of items.entries()) { + const where = `statusBar.items[${i}]`; + if (item === null || typeof item !== "object") fail(`${where} must be { id, label, live, panel }.`); + extra(item, STATUS_ITEM, where); + const { id, label, live = false, panel } = item; + if (typeof id !== "string" || !VIEW_NAME.test(id)) fail(`${where}.id must be a lowercase name.`); + if (ids.has(id)) fail(`${where}.id "${id}" is taken.`); + ids.add(id); + if (typeof label !== "string" || label === "") fail(`${where}.label must be its name.`); + if (typeof live !== "boolean") fail(`${where}.live must be true or false.`); + if (panel === null || typeof panel !== "object") fail(`${where}.panel must be { label, width }.`); + extra(panel, STATUS_PANEL, `${where}.panel`); + if (typeof panel.label !== "string" || panel.label === "") fail(`${where}.panel.label must be its name.`); + if (!Number.isInteger(panel.width) || panel.width <= 0) fail(`${where}.panel.width must be its width in pixels, a whole number.`); + } } // -- The Settings view ----------------------------------------------------- @@ -813,16 +850,154 @@ ])); } + // -- The bottom bar -------------------------------------------------------- + + /** The gap between the bar and a panel over it, and the least between a panel and the window's sides. */ + const PANEL_GAP = 6; + const PANEL_EDGE = 8; + + /** What can take the focus in a panel, for Tab past its last control (or before its first). */ + const FOCUSABLE = "button, a[href], input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex='-1'])"; + + /** + * The bar along the window's bottom, under the whole frame, and its items: + * each a borderless button the app fills, named by its label and its + * content, opening a non-modal panel above it, which the app fills too. + * Hidden until shown. + * @param {{ items: { id: string, label: string, live?: boolean, panel: { label: string, width: number } }[] }} options + * @param {HTMLElement} frame - the app's frame, which takes the rest of the window while the bar shows + * @param {(message: string) => never} fail + */ + function buildStatusBar({ items }, frame, fail) { + const bar = el("footer", { className: "kit-statusbar", attrs: { hidden: "" } }); + const entries = new Map(); + /** The item whose panel is open: one at a time. */ + let open = null; + + function place(entry) { + const width = Math.min(entry.width, innerWidth - 2 * PANEL_EDGE); + const left = Math.max(PANEL_EDGE, Math.min(entry.button.getBoundingClientRect().left, innerWidth - PANEL_EDGE - width)); + entry.dialog.style.width = `${width}px`; + entry.dialog.style.left = `${left}px`; + entry.dialog.style.bottom = `${bar.offsetHeight + PANEL_GAP}px`; + } + + function openPanel(entry) { + if (open === entry) return; + if (open) closePanel(open); + open = entry; + entry.button.setAttribute("aria-expanded", "true"); + entry.dialog.hidden = false; + place(entry); + entry.dialog.focus(); + } + + /** Close a panel; with focusBack, the focus goes back to its item (Escape, Close, Tab), and otherwise stays where it went. */ + function closePanel(entry, { focusBack = false } = {}) { + if (open !== entry) return; + open = null; + entry.dialog.hidden = true; + entry.button.setAttribute("aria-expanded", "false"); + if (focusBack) entry.button.focus(); + } + + for (const { id, label, live = false, panel } of items) { + const base = `kit-statusbar-${id}`; + // Named by its label, then what the app shows in it: "Connection wg-amsterdam 198.51.100.23". + const name = el("span", { text: label, attrs: { id: `${base}-label`, hidden: "" } }); + const button = el("button", { + className: "kit-statusbar-item", + attrs: { type: "button", id: base, "aria-haspopup": "dialog", "aria-expanded": "false", "aria-controls": `${base}-panel`, "aria-labelledby": `${base}-label ${base}` }, + }); + // Read out as it changes only where the app asks: the bar itself isn't a live region. + if (live) { + button.setAttribute("aria-live", "polite"); + button.setAttribute("aria-atomic", "true"); + } + const close = el("button", { className: "kit-statusbar-close", attrs: { type: "button", title: "Close", "aria-label": "Close" } }, [icon("close")]); + const body = el("div", { className: "kit-statusbar-panel-body" }); + const dialog = el("div", { + className: "kit-statusbar-panel", + attrs: { id: `${base}-panel`, role: "dialog", "aria-label": panel.label, tabindex: "-1", hidden: "" }, + }, [close, body]); + bar.append(name, button, dialog); + const entry = { button, dialog, width: panel.width }; + + button.addEventListener("click", () => { + if (open === entry) closePanel(entry); + else openPanel(entry); + }); + close.addEventListener("click", () => closePanel(entry, { focusBack: true })); + dialog.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closePanel(entry, { focusBack: true }); + return; + } + if (event.key !== "Tab") return; + // Tab past its last control, or Shift+Tab before its first, leaves it, closed, for its item. + const controls = [...dialog.querySelectorAll(FOCUSABLE)].filter((node) => !node.disabled && node.checkVisibility()); + const edge = event.shiftKey ? controls[0] : controls[controls.length - 1]; + if (controls.length === 0 || event.target === edge || (event.shiftKey && event.target === dialog)) { + event.preventDefault(); + closePanel(entry, { focusBack: true }); + } + }); + // The focus gone somewhere else (a click on a control, a prompt): never left open behind it. + dialog.addEventListener("focusout", (event) => { + const to = event.relatedTarget; + if (to && !dialog.contains(to) && to !== button) closePanel(entry); + }); + + entries.set(id, { + button, + panel: body, + open() { + if (bar.hidden) fail(`statusBar.item("${id}").open(): the bar is hidden; show it first.`); + openPanel(entry); + }, + close() { + closePanel(entry, { focusBack: dialog.contains(document.activeElement) }); + }, + get opened() { + return open === entry; + }, + }); + } + + // A click outside the open panel and its item closes it, leaving the focus where the click put it. + document.addEventListener("pointerdown", (event) => { + if (open && !open.dialog.contains(event.target) && !open.button.contains(event.target)) closePanel(open); + }, true); + addEventListener("resize", () => open && place(open)); + + frame.after(bar); + return { + show(visible) { + if (typeof visible !== "boolean") fail("statusBar.show() takes true or false."); + if (!visible && open) closePanel(open); + bar.hidden = !visible; + frame.classList.toggle("kit-with-statusbar", visible); + }, + item(id) { + if (!entries.has(id)) fail(`statusBar has no item "${id}".`); + return entries.get(id); + }, + }; + } + // -- The shell ------------------------------------------------------------- /** * Build the app's frame around its sections: the nav rail, the header and * the Settings view. See the top of this file. - * @returns {{ showView(view: string): void, showSettings(tab?: string, options?: { focus?: boolean }): void, ready: Promise }} + * @returns {{ showView(view: string): void, showSettings(tab?: string, options?: { focus?: boolean }): void, ready: Promise, + * statusBar: null | { show(visible: boolean): void, item(id: string): { button: HTMLButtonElement, panel: HTMLElement, open(): void, close(): void, opened: boolean } } }} */ function mountShell(options) { checkOptions(options); - const { title, sections = [], toolbar, settingsTabs = [], credits = {}, onViewChange, busy } = options; + const { title, sections = [], toolbar, settingsTabs = [], credits = {}, onViewChange, busy, statusBar } = options; if (document.querySelector(".app-frame")) throw new Error("kit.ui.mountShell() was called twice."); // The rail: the app's sections, then Settings, then Collapse. @@ -860,7 +1035,11 @@ element.classList.add("kit-view"); element.dataset.view = view; } - document.body.prepend(el("div", { className: "app-frame" }, [rail, shell])); + const frame = el("div", { className: "app-frame" }, [rail, shell]); + document.body.prepend(frame); + const bar = statusBar ? buildStatusBar(statusBar, frame, (message) => { + throw new Error(`kit.ui.mountShell(): ${message}`); + }) : null; for (const { id, render } of settingsTabs) render(settings.pane(id)); @@ -974,7 +1153,7 @@ if (status) showUpdate(status); }); - return { showView, showSettings, ready }; + return { showView, showSettings, ready, statusBar: bar }; } // -- Progress ------------------------------------------------------------------ @@ -1213,8 +1392,43 @@ /** A status line's tones: what each colours its dot and its text. running pulses. */ const CARD_TONES = ["ready", "running", "warning", "danger", "success", "muted"]; + /** + * A status line on its own, as a card draws its own: a dot coloured by + * its tone, then the text, cut off with an ellipsis, its whole text the + * title unless title says more (what a colour means, say). It's a span, + * so it can sit in a button, such as a bar item's. set() takes the whole + * of it again, as a card's update() does. + * @param {{ text: string, tone: string, title?: string }} options + * @returns {{ element: HTMLElement, set(state: { text: string, tone: string, title?: string }): void }} + */ + function statusLine(options) { + const fail = (message) => { + throw new Error(`kit.ui.statusLine(): ${message}`); + }; + const check = (given, where) => { + if (given === null || typeof given !== "object") fail(`${where} takes { text, tone, title }.`); + const extra = Object.keys(given).find((key) => !["text", "tone", "title"].includes(key)); + if (extra !== undefined) fail(`${where} has no option "${extra}".`); + const { text, tone, title = text } = given; + if (typeof text !== "string") fail(`${where}: text must be text.`); + if (!CARD_TONES.includes(tone)) fail(`${where}: tone must be one of ${CARD_TONES.join(", ")}.`); + if (typeof title !== "string") fail(`${where}: title must be text, or left out.`); + return { text, tone, title }; + }; + const first = check(options, "it"); + const textPart = el("span", { className: "kit-status-text" }); + const element = el("span", { className: "kit-card-status" }, [el("span", { className: "kit-status-dot" }), textPart]); + function draw({ text, tone, title }) { + element.className = `kit-card-status kit-tone-${tone}`; + textPart.textContent = text; + textPart.title = title; + } + draw(first); + return { element, set: (state) => draw(check(state, "set()")) }; + } + /** What a card's update() takes. */ - const CARD_STATE = ["name", "meta", "settings", "status", "percent", "paused", "actions", "rows", "locked", "canRemove", "route"]; + const CARD_STATE =["name", "meta", "settings", "status", "percent", "paused", "actions", "rows", "locked", "canRemove", "route"]; /** The elements a click on a card belongs to rather than the card: a click on one of them doesn't pick it. */ const CARD_CONTROLS = "button, input, select, textarea, a[href], label, summary, [contenteditable], [tabindex]"; @@ -1309,8 +1523,7 @@ : [el("span", { className: "kit-card-from", text: route.from }), icon("arrow_forward", "kit-card-arrow")]), route.control, ]); - const statusText = el("span", { className: "kit-status-text" }); - const status = el("div", { className: "kit-card-status" }, [el("span", { className: "kit-status-dot" }), statusText]); + const status = statusLine({ text: "", tone: "muted" }); const bar = progress({ label: name, thin: true }); bar.element.classList.add("kit-card-progress"); const rowSlot = el("div", { className: "kit-card-rows" }); @@ -1321,7 +1534,7 @@ meta, settings, routeLine, - status, + status.element, bar.element, rowSlot, actionSlot, @@ -1381,7 +1594,7 @@ if (state === null || typeof state !== "object") fail("update() takes the card's state, { status, … }."); unknown(state, CARD_STATE, "update()"); const { - name: renamed, meta: metaText, settings: settingsLine, status: statusLine, percent, paused = false, + name: renamed, meta: metaText, settings: settingsLine, status: statusOf, percent, paused = false, actions = [], rows = [], locked = false, canRemove = !locked, route: showRoute = true, } = state; // Everything is checked before anything is drawn, so a mistake leaves the card as it was. @@ -1392,7 +1605,7 @@ || (settingsOf.tone !== undefined && settingsOf.tone !== "warning"))) { fail('update(): settings must be text, or { text, tone: "warning" }.'); } - if (statusLine !== undefined && (statusLine === null || typeof statusLine !== "object" || typeof statusLine.text !== "string" || !CARD_TONES.includes(statusLine.tone))) { + if (statusOf !== undefined && (statusOf === null || typeof statusOf !== "object" || typeof statusOf.text !== "string" || !CARD_TONES.includes(statusOf.tone))) { fail(`update(): status must be { text, tone }, the tone one of ${CARD_TONES.join(", ")}.`); } if (percent !== undefined && percent !== null && !Number.isFinite(percent)) fail("update(): percent must be a number, or null when it isn't known."); @@ -1419,10 +1632,8 @@ settings.title = settingsOf?.text ?? ""; settings.classList.toggle("kit-card-settings-warning", settingsOf?.tone === "warning"); if (routeLine) routeLine.hidden = !showRoute; - status.hidden = statusLine === undefined; - status.className = `kit-card-status kit-tone-${statusLine?.tone ?? "muted"}`; - statusText.textContent = statusLine?.text ?? ""; - statusText.title = statusLine?.text ?? ""; + status.element.hidden = statusOf === undefined; + status.set({ text: statusOf?.text ?? "", tone: statusOf?.tone ?? "muted" }); bar.element.hidden = percent === undefined; if (percent !== undefined) bar.set(percent, { paused }); // The app's rows, put back only when they're other elements, so one being typed in or selected keeps it. @@ -2086,7 +2297,7 @@ }, true); window.kit = { - ui: { mountShell, toast, confirm, progress, actionBar, dropZone, busy, card, cardGrid, bulkBar, switchField, stepSlider }, + ui: { mountShell, toast, confirm, progress, actionBar, dropZone, busy, statusLine, card, cardGrid, bulkBar, switchField, stepSlider }, format: { plural, countOf, groupDigits, formatBytes, formatEta, formatDuration, summarise }, keys: { isTyping, onKey }, }; diff --git a/test/kit-css.test.js b/test/kit-css.test.js index 5455aef..432d2e3 100644 --- a/test/kit-css.test.js +++ b/test/kit-css.test.js @@ -270,8 +270,9 @@ test("every focus ring the kit draws is a solid ring in the accent's text shade, assert.equal(rule(".btn")["--bs-btn-focus-box-shadow"], "none"); assert.equal(rule(".btn-close")["--bs-btn-close-focus-shadow"], "none"); assert.equal(rule(".form-control:focus, .form-select:focus")["box-shadow"], "none"); - // Nothing that can take focus goes without a ring. - assert.deepEqual(KIT_RULES.filter((r) => r.declarations.outline === "none" || r.declarations.outline === "0").map((r) => r.selector), []); + // Nothing that can take focus goes without a ring, but a bar item's panel itself: it takes the focus as it opens, + // so a screen reader reads its name, and Tab goes on to its controls, which have theirs. + assert.deepEqual(KIT_RULES.filter((r) => r.declarations.outline === "none" || r.declarations.outline === "0").map((r) => r.selector), [".kit-statusbar-panel:focus"]); }); test("every range's knob is the accent's fill, its hover shade while pressed, with no glow; Bootstrap's grey when disabled", () => {