diff --git a/css/kit.css b/css/kit.css index a8f914e..f9a0876 100644 --- a/css/kit.css +++ b/css/kit.css @@ -1594,7 +1594,8 @@ a:hover { } /* -- Prompts -------------------------------------------------------------------- - kit.ui.confirm(): a native modal , drawn as Dropgate's Upload + kit.ui.confirm(), and kit.ui.dialog()'s forms with it, so the two can't + drift: a native modal , drawn as Dropgate's Upload Security Warning, a Bootstrap centred modal, is (Will, D73): its surface, border and radius with no shadow, a half-black backdrop, no rules between header, body and footer, and Bootstrap's own spacing; the glyph beside the @@ -1686,6 +1687,20 @@ a:hover { margin: 1rem 0 0; } +/* kit.ui.dialog(): the same modal, its body the app's own form rows, a little + apart, as Dropgate's Advanced Options has them. */ +.kit-form-dialog .kit-dialog-body { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* The house's form row: the label, the control, then a help line that keeps + its height, so nothing under it jumps as its text comes and goes. */ +.form-text.reserved { + min-height: 1.5em; +} + /* Cancel first, on the left; the buttons wrap on a narrow window. Bootstrap's footer spacing: 0.75rem round the edge, and 0.25rem round each button. */ .kit-dialog-footer { diff --git a/demo/src/renderer.js b/demo/src/renderer.js index 81b2788..002cb75 100644 --- a/demo/src/renderer.js +++ b/demo/src/renderer.js @@ -140,10 +140,11 @@ function transfer(spec) { const card = window.kit.ui.card({ icon: spec.icon, name: spec.name, - onConfigure: says("Advanced Options open here."), + onConfigure: upload ? () => openAdvanced([card]) : says("Advanced Options open here."), onRemove: () => queue.remove(card), route: { label: upload ? "To" : "Save to", control: select }, }); + item.options = { lifetime: { value: 7, unit: "days" }, downloads: 10 }; item.send = (server) => { item.server = server; select.value = server; @@ -207,7 +208,10 @@ function fillQueue() { note: (scope, { all }) => (waiting(scope).length === 0 ? `No ${all ? "" : "selected "}upload is waiting to start` : ""), }, actions: [ - { label: "Bulk Edit", variant: "primary", selection: true, onClick: says("Advanced Options open here.") }, + { + label: "Bulk Edit", variant: "primary", selection: true, + onClick: (scope) => (waiting(scope).length > 0 ? openAdvanced(waiting(scope)) : window.kit.ui.toast("No selected upload is waiting to start.", { type: "info" })), + }, { label: "Remove", variant: "outline-danger", selection: true, enabled: (scope) => scope.some((card) => items.get(card).removable), onClick: removeAll }, ], }); @@ -235,6 +239,116 @@ function fillQueue() { ]) queue.add(card); } +// -- Advanced Options --------------------------------------------------------------- +// +// kit.ui.dialog() as Dropgate uses it, from an upload's Advanced Options or the +// bar's Bulk Edit: how long the server keeps the files, and how many times +// they can be downloaded, each row with its help line, and Apply turned off +// while a value is more than the server allows. + +/** What the demo's servers allow. */ +const MOST = { days: 30, downloads: 100 }; + +/** A form row, the house's way: its label, the control, and a help line that keeps its height and describes it. */ +function formRow(id, label, control) { + const row = document.createElement("div"); + const labelElement = document.createElement("label"); + labelElement.className = "form-label"; + labelElement.htmlFor = id; + labelElement.textContent = label; + const help = document.createElement("div"); + help.className = "form-text reserved"; + help.id = `${id}-help`; + const field = control.matches("input, select") ? control : control.querySelector("input"); + field.id = id; + for (const each of control.matches("input, select") ? [control] : control.querySelectorAll("input, select")) each.setAttribute("aria-describedby", help.id); + row.append(labelElement, control, help); + /** Its help line, plain or saying what's wrong. */ + row.say = (text, wrong) => { + help.textContent = text; + help.classList.toggle("text-danger-emphasis", wrong); + }; + return row; +} + +/** A number field. */ +function numberField(value, { min, max }) { + const input = document.createElement("input"); + input.className = "form-control form-control-sm"; + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.step = "1"; + input.value = String(value); + return input; +} + +/** "7 Days · 10 Downloads": what's been set on an upload, for its settings line. */ +function optionsText({ lifetime, downloads }) { + const { plural } = window.kit.format; + return `${lifetime.value} ${plural(lifetime.value, lifetime.unit === "days" ? "Day" : "Hour")} · ${downloads} ${plural(downloads, "Download")}`; +} + +/** Advanced Options for these uploads, the first's options to start with; Apply sets them on every one. */ +async function openAdvanced(cards) { + const { countOf } = window.kit.format; + const [first] = cards.map((card) => items.get(card)); + const about = document.createElement("p"); + about.className = "kit-dialog-text"; + about.textContent = cards.length > 1 ? `For the ${countOf(cards.length, "upload")} selected.` : first.name; + + const lifetimeValue = numberField(first.options.lifetime.value, { min: 1, max: MOST.days }); + const unit = document.createElement("select"); + unit.className = "form-select form-select-sm"; + unit.setAttribute("aria-label", "Unit"); + for (const [value, label] of [["hours", "Hours"], ["days", "Days"]]) unit.append(new Option(label, value)); + unit.value = first.options.lifetime.unit; + const group = document.createElement("div"); + group.className = "input-group input-group-sm"; + group.append(lifetimeValue, unit); + const lifetimeRow = formRow("advanced-lifetime", "File lifetime", group); + const downloads = numberField(first.options.downloads, { min: 1, max: MOST.downloads }); + const downloadsRow = formRow("advanced-downloads", "Max downloads", downloads); + + const dialog = window.kit.ui.dialog({ + title: "Advanced Options", + icon: "tune", + body: [about, lifetimeRow, downloadsRow], + actions: [ + { label: "Cancel", value: false, cancel: true }, + { label: "Apply", value: true, variant: "primary" }, + ], + }); + + /** The whole number a field holds, or null. */ + const whole = (input) => (/^\d+$/.test(input.value.trim()) ? Number(input.value) : null); + /** Each help line says what the server allows, or what's wrong; Apply is off while something is. */ + function check() { + lifetimeValue.max = String(unit.value === "days" ? MOST.days : MOST.days * 24); + const life = whole(lifetimeValue); + const days = life === null ? null : (unit.value === "days" ? life : life / 24); + const lifeWrong = life === null || life === 0 ? "Enter a lifetime of more than 0." : days > MOST.days ? `Too long: The server keeps files up to ${MOST.days} days.` : null; + lifetimeRow.say(lifeWrong ?? `The server keeps files up to ${MOST.days} days.`, lifeWrong !== null); + const n = whole(downloads); + const downloadsWrong = n === null || n < 1 || n > MOST.downloads ? `Enter a whole number from 1 to ${MOST.downloads}.` : null; + downloadsRow.say(downloadsWrong ?? `The server allows up to ${MOST.downloads} downloads.`, downloadsWrong !== null); + dialog.action(true).disabled = lifeWrong !== null || downloadsWrong !== null; + } + for (const field of [lifetimeValue, downloads]) field.addEventListener("input", check); + unit.addEventListener("change", check); + check(); + + if (!await dialog.closed) return; + const options = { lifetime: { value: whole(lifetimeValue), unit: unit.value }, downloads: whole(downloads) }; + for (const card of cards) { + const item = items.get(card); + item.options = options; + // A warning on the line stays: it says more than the options do. + if (typeof item.settings !== "object") item.settings = optionsText(options); + item.draw(); + } +} + /** The Controls' stepped slider: how many files are worked on at once. */ function fillControls() { const { countOf } = window.kit.format; diff --git a/docs/README.md b/docs/README.md index 3877cc7..4aae1e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ 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, 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 | +| [Page Components](page-components.md) | Toasts, prompts, form dialogs and the house's form row, 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, its state, and a proxy | | [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 3e97af8..c8406d1 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 78 images: +`e2e/visual.spec.js-snapshots/`, using Playwright's `toHaveScreenshot()`. It takes 86 images: | State | Accents | Themes | |---|---|---| @@ -58,6 +58,7 @@ package that's already on npm. | 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 | +| A form (`kit.ui.dialog()`): the demo's Advanced Options over the Queue, its first field focused | 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 10f910f..56f27de 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 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. +The page library's parts: toasts, prompts, form dialogs, 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) @@ -36,7 +36,7 @@ with the answer. It's drawn as Dropgate's Upload Security Warning, a Bootstrap c beside the title (in a warning's or a danger's colour for those), the body in the page's text, an optional `detail` under it in small grey text, Cancel a filled grey button, and Bootstrap's spacing, sliding down into place. It's a native modal `` (`role="alertdialog"`), so it needs no Bootstrap script: the page behind can't be reached while it's open, Tab stays inside it, -and the focus goes back to what had it once it closes. Prompts are asked one at a time: one asked while +and the focus goes back to what had it once it closes. Prompts are asked one at a time, with the form dialogs below: one asked while another shows waits until that one is answered. Everything in it is text, never markup. ```js @@ -78,6 +78,90 @@ unticked each time). Each choice is `btn-secondary` unless it names its `variant for the batch, such as a Cancel All ending the prompts still to come, is the app's. A mistake in the options throws before anything shows. +## Form Dialogs + +`kit.ui.dialog(options)` is a small form in the same modal as a prompt, for an app's own options: Advanced +Options on an upload, Add Server. The kit draws the title, the buttons and the way out; the body is the app's own +elements. + +```js +const lifetime = formRow("advanced-lifetime", "File lifetime", lifetimeGroup); // the app's own form rows +const downloads = formRow("advanced-downloads", "Max downloads", downloadsField); + +const dialog = kit.ui.dialog({ + title: "Advanced Options", // Title Case + icon: "tune", // its Material Icons Round glyph + body: [nameLine, lifetime, downloads], // an element, or a list of them + actions: [ + { label: "Cancel", value: false, cancel: true }, + { label: "Apply", value: true, variant: "primary" }, + ], +}); +dialog.action(true).disabled = !valid; // Apply off while a value is outside the server's limits +if (await dialog.closed) apply(); +``` + +- **It's drawn and animated as a prompt is**, from the same rules (`.kit-dialog`): the glyph beside the title, the + close button, Bootstrap's spacing, sliding down into place, with the body's rows `0.75rem` apart. It's a native + modal `` named by its title, so the page behind can't be reached and Tab stays inside it. It's in the page + only while it shows: `element` is the ``. +- **The actions** are its buttons, left to right, each `{ label, value, variant }`: `btn-secondary` unless it names its + `variant` (`primary`, `secondary`, `success`, `warning`, `danger`, `outline-secondary` or `outline-danger`). At + most one is `primary`, and at most one is the `cancel: true` action. Their values are any value but `undefined`, + each different. +- **The ways out:** a button gives its value; Escape and the close button give the cancel action's value, or `false` + with none. **A click on the backdrop does nothing**: what was typed is never thrown away by a stray click. `closed` + resolves with the value, once it has faded out. +- **Enter in a field** (a text, number or other one-line input) presses the primary button, unless it's disabled. + Enter in a text area is a new line, and on a button it presses that button. +- **The focus** starts on the first field that can take it (a hidden row or a disabled field is passed over); with + none, on the primary button, or, while that's disabled, on the last button that isn't. It goes back to what had it + once the form closes. +- **`action(value)`** is that action's button, for the app to turn on and off (Apply while a field is invalid), or to + listen to. **`close(value)`** closes it from the app, faded out like a button press, with that value. +- **A button that leaves it open:** an action with `close: false` doesn't close the form when it's pressed (or when + Enter presses it). The app listens to it and calls `close(value)` once its work is done, as Add Server's Check does: + it checks the address, says what it found, and turns into Add Server. + + ```js + const check = dialog.action("check"); // { label: "Check", value: "check", variant: "primary", close: false } + check.addEventListener("click", async () => { + const restore = kit.ui.busy(check, "Checking…"); + const found = await checkServer(address.value).finally(restore); + if (found) dialog.close(found); + }); + ``` + +- **One at a time, with the prompts:** a form opened while a prompt or another form shows waits its turn, and a + prompt asked while a form shows waits for it, either way round. A form closed by the app before its turn never + shows; its `closed` resolves at once. +- A mistake in the options, in `action()`'s value, or a `close()` without a value throws. + +**The house's form row** is the label, the control, then a help line under it that keeps its height +(`.form-text.reserved`, which the kit's CSS gives `min-height: 1.5em`), so nothing below it jumps as its text comes +and goes. The help line describes the control (`aria-describedby`), says what's allowed, and says what's wrong, in +`text-danger-emphasis`, while it is: + +```js +function formRow(id, label, control) { + const row = document.createElement("div"); + const labelElement = document.createElement("label"); + labelElement.className = "form-label"; + labelElement.htmlFor = id; + labelElement.textContent = label; + const help = document.createElement("div"); + help.className = "form-text reserved"; + help.id = `${id}-help`; + control.id = id; // a group's input takes the id instead + control.setAttribute("aria-describedby", help.id); + row.append(labelElement, control, help); + return row; +} +``` + +The words follow the copy rules below: "Too long: The server keeps files up to 30 days." A switch in a form is +`kit.ui.switchField()`, whose help line already says what each state does. + ## A Section of Files The parts of a section that works through a list of files, each built with `createElement` and optional: @@ -388,7 +472,7 @@ atOnce.input.disabled = true; // the range itself, for anything else `kit.keys.onKey(handler, { view })` listens for a section's own shortcuts (Delete, Escape, `Ctrl+A` on a list), and returns a function that stops listening. The handler isn't called while someone is typing, while a modal -is open (a prompt, or one of Bootstrap's), or, given a `view`, while another view shows. `kit.keys.isTyping()` +is open (a prompt, a form, or one of Bootstrap's), or, given a `view`, while another view shows. `kit.keys.isTyping()` says whether a key goes into something being typed in: a text field, a text area or anything editable. A checkbox isn't, and neither is a select, which keeps the focus after a choice and would swallow the next Escape. diff --git a/e2e/accessibility.spec.js b/e2e/accessibility.spec.js index acd780e..3275c7d 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; and the bottom bar, shown, and with its panel open. Each +// demo's in turn; the bottom bar, shown, and with its panel open; and a form +// (kit.ui.dialog(), the demo's Advanced Options) open. Each // theme is switched as the OS switches it (nativeTheme, // from main), and the kit's theme.js draws the page in it. // @@ -102,6 +103,35 @@ test("axe finds nothing in the bottom bar, or with its panel open, in both theme expect(found).toEqual([]); }); +test("axe finds nothing with a form open (kit.ui.dialog()), its help lines plain or saying what's wrong, in both themes, in the demo's accent and each app's", async ({ demo }) => { + const main = await demo.mainWindow(); + await main.getByRole("button", { name: "Queue", exact: true }).click(); + const opener = main.getByRole("group", { name: "printer-manual.pdf", exact: true }).getByRole("button", { name: "Advanced Options" }); + const dialog = main.getByRole("dialog", { name: "Advanced Options" }); + 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 wrong of [false, true]) { + await opener.click(); + // Too long a lifetime: its help line in the danger shade, Apply disabled. + if (wrong) await dialog.getByRole("spinbutton", { name: "File lifetime" }).fill("99"); + await main.mouse.move(0, 0); + await main.evaluate(() => Promise.all(document.getAnimations() + .filter((a) => a.effect?.getComputedTiming().iterations !== Infinity) + .map((a) => a.finished.catch(() => {})))); + const place = `${name ?? "the demo"}'s accent, the ${theme} theme, a form${wrong ? " saying what's wrong" : ""}`; + found.push(...(await violations(main)).map((line) => `${place}: ${line}`)); + await main.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + } + } + 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/dialog.spec.js b/e2e/dialog.spec.js new file mode 100644 index 0000000..3e49b83 --- /dev/null +++ b/e2e/dialog.spec.js @@ -0,0 +1,488 @@ +"use strict"; + +// kit.ui.dialog(), a small form in the prompts' modal, in real Electron: its +// anatomy and names, the focus going in and coming back, each way out and the +// value it gives, no close on the backdrop, Enter pressing the primary button +// only while it's enabled, an action that leaves it open, its turn in the +// queue with kit.ui.confirm() both ways round, the refusals, and the demo's +// Advanced Options. axe on it is in accessibility.spec.js, its rings in +// keyboard.spec.js. + +const { test, expect } = require("./helpers/demo"); +const { focused } = require("./helpers/focus"); + +/** A form's actions, as Dropgate's Advanced Options has them. */ +const CANCEL_APPLY = [ + { label: "Cancel", value: false, cancel: true }, + { label: "Apply", value: true, variant: "primary" }, +]; + +/** Two rows: a text field and a number. */ +const TWO_FIELDS = [{ id: "f-name", label: "Name" }, { id: "f-count", label: "Count", type: "number" }]; + +/** + * Open a form from the page. Its body is described, since elements can't + * cross from the test: `{ text }` is a line of text; anything else a house + * form row (its label, the control, a reserved help line). Its value lands + * on window.results, in the order forms and prompts were opened, and the + * dialog on window.forms. With `apply: false`, Apply starts disabled. + */ +function openForm(page, { title = "Advanced Options", icon = "tune", body = TWO_FIELDS, actions = CANCEL_APPLY, apply = true } = {}) { + return page.evaluate(({ title, icon, body, actions, apply }) => { + const parts = body.map(({ text, id, label, type = "text", disabled = false, hidden = false }) => { + if (text !== undefined) { + const line = document.createElement("p"); + line.className = "kit-dialog-text"; + line.textContent = text; + return line; + } + const row = document.createElement("div"); + row.hidden = hidden; + const control = document.createElement(type === "textarea" ? "textarea" : "input"); + if (type !== "textarea") control.type = type; + control.id = id; + control.className = type === "checkbox" ? "form-check-input" : "form-control form-control-sm"; + control.disabled = disabled; + const labelElement = document.createElement("label"); + labelElement.className = type === "checkbox" ? "form-check-label" : "form-label"; + labelElement.htmlFor = id; + labelElement.textContent = label; + const help = document.createElement("div"); + help.className = "form-text reserved"; + help.id = `${id}-help`; + control.setAttribute("aria-describedby", help.id); + row.append(...(type === "checkbox" ? [control, labelElement] : [labelElement, control]), help); + return row; + }); + window.results ??= []; + window.forms ??= []; + const i = window.results.push(undefined) - 1; + const dialog = window.kit.ui.dialog({ title, icon, body: parts, actions }); + if (!apply) dialog.action(true).disabled = true; + window.forms.push(dialog); + dialog.closed.then((value) => { + window.results[i] = value; + }); + }, { title, icon, body, actions, apply }); +} + +/** Ask a prompt from the page, its answer on window.results too, in turn with the forms. */ +function ask(page, options) { + return page.evaluate((options) => { + window.results ??= []; + const i = window.results.push(undefined) - 1; + window.kit.ui.confirm(options).then((answer) => { + window.results[i] = answer; + }); + }, options); +} + +/** Every value, in the order the forms and prompts were opened, once each has resolved. */ +async function results(page) { + await page.waitForFunction(() => (window.results ?? []).every((value) => value !== undefined)); + return page.evaluate(() => window.results); +} + +/** Wait for every transition and animation running in the page to end. */ +const settled = (page) => page.evaluate(() => Promise.all(document.getAnimations() + .filter((a) => a.effect?.getComputedTiming().iterations !== Infinity) + .map((a) => a.finished.catch(() => {})))); + +/** What has the focus: its role and name. */ +const where = async (page) => { + const { role, name } = await focused(page); + return `${role} ${name}`; +}; + +test("a form's anatomy: a modal named by its title, the glyph, the app's own rows, the actions left to right, drawn as a prompt is", async ({ demo }) => { + const main = await demo.mainWindow(); + // Not in the page until it shows. + await expect(main.locator("dialog.kit-form-dialog")).toHaveCount(0); + await openForm(main, { actions: [...CANCEL_APPLY, { label: "Reset", value: "reset", variant: "outline-secondary" }] }); + const dialog = main.getByRole("dialog", { name: "Advanced Options" }); + await expect(dialog).toBeVisible(); + expect(await dialog.evaluate((d) => d.matches(":modal") && d.classList.contains("kit-dialog"))).toBe(true); + await expect(dialog.getByRole("heading", { level: 2, name: "Advanced Options" })).toBeVisible(); + await expect(dialog.locator(".kit-dialog-icon")).toHaveText("tune"); + await expect(dialog.locator(".kit-dialog-icon")).toHaveAttribute("aria-hidden", "true"); + await expect(dialog.getByRole("textbox", { name: "Name" })).toBeVisible(); + await expect(dialog.getByRole("spinbutton", { name: "Count" })).toBeVisible(); + await expect(dialog.locator(".kit-dialog-body .form-text.reserved")).toHaveCount(2); + const footer = dialog.locator(".kit-dialog-footer .btn"); + await expect(footer).toHaveText(["Cancel", "Apply", "Reset"]); + expect(await footer.evaluateAll((els) => els.map((el) => el.className))).toEqual(["btn btn-secondary", "btn btn-primary", "btn btn-outline-secondary"]); + for (const name of ["Close", "Cancel", "Apply", "Reset"]) await expect(dialog.getByRole("button", { name, exact: true })).toBeVisible(); + + // A prompt's look, from the same rules: its width, its edge, its spacing; the rows a little apart. + await settled(main); + const look = await dialog.evaluate((d) => { + const style = (sel) => getComputedStyle(d.querySelector(sel)); + return { + width: d.getBoundingClientRect().width, + shadow: getComputedStyle(d).boxShadow, + padding: [style(".kit-dialog-header").padding, style(".kit-dialog-body").padding, style(".kit-dialog-footer").padding], + gap: style(".kit-dialog-body").rowGap, + help: style(".form-text.reserved").minHeight, + helpLine: style(".form-text.reserved").lineHeight, + }; + }); + expect(look).toEqual({ width: 500, shadow: "none", padding: ["16px", "16px", "12px"], gap: "12px", help: look.helpLine, helpLine: look.helpLine }); + // An empty help line keeps its height. + expect(await dialog.locator(".form-text.reserved").first().evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(15); + await main.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + // Gone from the page once it's closed. + await expect(main.locator("dialog.kit-form-dialog")).toHaveCount(0); + + // It slides down into place, as a prompt does. + await openForm(main); + const frames = await main.locator("dialog[open]").evaluate((d) => d.getAnimations().map((a) => a.effect.getKeyframes().map((k) => k.transform))); + expect(frames).toEqual([["translateY(-50px)", "none"]]); + await main.keyboard.press("Escape"); + expect(await results(main)).toEqual([false, false]); +}); + +test("the focus starts on the first field it can take, Tab stays inside, and the focus goes back on close", async ({ demo }) => { + const main = await demo.mainWindow(); + const opener = main.getByRole("button", { name: "Open Isolated Window" }); + const dialog = main.locator("dialog[open]"); + + // A line of text, a hidden row and a disabled field are passed over. + await opener.focus(); + await openForm(main, { body: [{ text: "clip.mp4" }, { id: "f-gone", label: "Gone", hidden: true }, { id: "f-off", label: "Off", disabled: true }, ...TWO_FIELDS] }); + await expect(main.getByRole("textbox", { name: "Name" })).toBeFocused(); + const stops = []; + for (let i = 0; i < 5; i++) { + await main.keyboard.press("Tab"); + stops.push(await where(main)); + } + // After Apply, round again: the page behind can't be reached. + expect(stops).toEqual(["spinbutton Count", "button Cancel", "button Apply", "button Close", "textbox Name"]); + await main.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(opener).toBeFocused(); + + // A checkbox is a field too. + await opener.focus(); + await openForm(main, { body: [{ text: "Nothing to type." }, { id: "f-box", label: "Keep a copy", type: "checkbox" }] }); + expect(await where(main)).toBe("checkbox Keep a copy"); + await main.getByRole("button", { name: "Apply" }).click(); + await expect(dialog).toHaveCount(0); + await expect(opener).toBeFocused(); + + // No field: the primary button; while it's disabled, the last button that isn't. + await openForm(main, { body: [{ text: "Nothing to change." }] }); + expect(await where(main)).toBe("button Apply"); + await main.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await openForm(main, { body: [{ text: "Nothing to change." }], apply: false }); + expect(await where(main)).toBe("button Cancel"); + await main.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + expect(await results(main)).toEqual([false, true, false, false]); +}); + +test("each way out gives its value: an action's, the cancel action's for Escape and Close, false without one, and the app's close()", async ({ demo }) => { + const main = await demo.mainWindow(); + const dialog = main.getByRole("dialog", { name: "Advanced Options" }); + const actions = [{ label: "Cancel", value: "cancelled", cancel: true }, { label: "Keep Both", value: { both: true } }, { label: "Apply", value: 3, variant: "primary" }]; + const ways = [ + async () => dialog.getByRole("button", { name: "Apply" }).click(), + async () => dialog.getByRole("button", { name: "Keep Both" }).click(), + async () => dialog.getByRole("button", { name: "Cancel" }).click(), + async () => main.keyboard.press("Escape"), + async () => dialog.getByRole("button", { name: "Close" }).click(), + async () => main.evaluate(() => window.forms.at(-1).close("from the app")), + ]; + for (const way of ways) { + await openForm(main, { actions }); + await expect(dialog).toBeVisible(); + await way(); + await expect(dialog).toHaveCount(0); + } + // With no cancel action, Escape and Close give false. + for (const key of ["Escape", "Close"]) { + await openForm(main, { actions: [{ label: "Done", value: "done", variant: "primary" }] }); + if (key === "Escape") await main.keyboard.press("Escape"); + else await dialog.getByRole("button", { name: "Close" }).click(); + await expect(dialog).toHaveCount(0); + } + expect(await results(main)).toEqual([3, { both: true }, "cancelled", "cancelled", "cancelled", "from the app", false, false]); + + // A second close, or a button pressed after it, changes nothing. + await openForm(main); + await main.evaluate(() => { + const form = window.forms.at(-1); + form.close("first"); + form.close("second"); + form.action(true).click(); + }); + await expect(dialog).toHaveCount(0); + expect((await results(main)).at(-1)).toBe("first"); +}); + +test("a click on the backdrop doesn't close a form, or throw away what was typed", async ({ demo }) => { + const main = await demo.mainWindow(); + await openForm(main); + const dialog = main.getByRole("dialog", { name: "Advanced Options" }); + await main.getByRole("textbox", { name: "Name" }).fill("Typed"); + // The window's corner, outside the dialog, then just beyond each edge. + const box = await dialog.boundingBox(); + for (const [x, y] of [[5, 5], [box.x - 4, box.y + box.height / 2], [box.x + box.width + 4, box.y + 10], [box.x + 10, box.y + box.height + 4]]) { + await main.mouse.click(x, y); + } + await main.waitForTimeout(500); + await expect(dialog).toBeVisible(); + await expect(main.getByRole("textbox", { name: "Name" })).toHaveValue("Typed"); + await main.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + expect(await results(main)).toEqual([false]); +}); + +test("Enter in a field presses the primary button, only while it's enabled; not in a text area or on a checkbox", async ({ demo }) => { + const main = await demo.mainWindow(); + const dialog = main.locator("dialog[open]"); + const body = [...TWO_FIELDS, { id: "f-notes", label: "Notes", type: "textarea" }, { id: "f-box", label: "Keep a copy", type: "checkbox" }]; + + await openForm(main, { body, apply: false }); + const name = main.getByRole("textbox", { name: "Name" }); + await name.fill("clip"); + await name.press("Enter"); + // Apply is disabled: nothing. + await main.waitForTimeout(300); + await expect(dialog).toHaveCount(1); + // Enter in a text area is a new line; on a checkbox, nothing. + await main.getByRole("textbox", { name: "Notes" }).press("Enter"); + await main.getByRole("checkbox", { name: "Keep a copy" }).press("Enter"); + await main.evaluate(() => { + window.forms.at(-1).action(true).disabled = false; + }); + await main.getByRole("textbox", { name: "Notes" }).press("Enter"); + await main.getByRole("checkbox", { name: "Keep a copy" }).press("Enter"); + await main.waitForTimeout(300); + await expect(dialog).toHaveCount(1); + await expect(main.getByRole("textbox", { name: "Notes" })).toHaveValue("\n\n"); + // Enabled now, Enter in a field is Apply. + await main.getByRole("spinbutton", { name: "Count" }).press("Enter"); + await expect(dialog).toHaveCount(0); + + // Enter on a button presses that button. + await openForm(main); + await main.getByRole("button", { name: "Cancel" }).focus(); + await main.keyboard.press("Enter"); + await expect(dialog).toHaveCount(0); + + // With no primary action, Enter in a field presses nothing. + await openForm(main, { actions: [{ label: "Cancel", value: false, cancel: true }, { label: "Save", value: "save" }] }); + await main.getByRole("textbox", { name: "Name" }).press("Enter"); + await main.waitForTimeout(300); + await expect(dialog).toHaveCount(1); + await main.keyboard.press("Escape"); + expect(await results(main)).toEqual([true, false, false]); +}); + +test("an action with close: false leaves it open for the app, which closes it once its work is done", async ({ demo }) => { + const main = await demo.mainWindow(); + const dialog = main.getByRole("dialog", { name: "Add Server" }); + await openForm(main, { + title: "Add Server", + icon: "dns", + body: [{ id: "f-address", label: "Server address" }], + actions: [{ label: "Cancel", value: false, cancel: true }, { label: "Check", value: "check", variant: "primary", close: false }], + }); + await main.evaluate(() => { + const form = window.forms.at(-1); + window.checks = 0; + // As Dropgate's Add Server: Check, then Add Server, which closes it. + form.action("check").addEventListener("click", () => { + window.checks++; + if (window.checks === 2) form.close("added"); + else form.action("check").textContent = "Add Server"; + }); + }); + await main.getByRole("textbox", { name: "Server address" }).fill("https://drop.example.com"); + await main.getByRole("textbox", { name: "Server address" }).press("Enter"); + await expect(dialog.getByRole("button", { name: "Add Server" })).toBeVisible(); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Add Server" }).click(); + await expect(dialog).toHaveCount(0); + expect(await main.evaluate(() => window.checks)).toBe(2); + expect(await results(main)).toEqual(["added"]); +}); + +test("a form and a prompt take turns, either way round, one at a time; a form closed before its turn never shows", async ({ demo }) => { + const main = await demo.mainWindow(); + const open = main.locator("dialog[open]"); + const prompt = { title: "Clear the List", body: "Every card goes.", confirmLabel: "Clear" }; + + // A prompt, then a form asked while it shows. + await ask(main, prompt); + await openForm(main); + await expect(open).toHaveCount(1); + await expect(main.getByRole("alertdialog", { name: "Clear the List" })).toBeVisible(); + await expect(main.locator("dialog.kit-form-dialog")).toHaveCount(0); + await main.getByRole("button", { name: "Clear", exact: true }).click(); + await expect(main.getByRole("dialog", { name: "Advanced Options" })).toBeVisible(); + await expect(open).toHaveCount(1); + await expect(main.getByRole("textbox", { name: "Name" })).toBeFocused(); + + // A form, then a prompt asked while it shows. + await ask(main, prompt); + await main.waitForTimeout(300); + await expect(open).toHaveCount(1); + await expect(main.getByRole("dialog", { name: "Advanced Options" })).toBeVisible(); + await main.getByRole("button", { name: "Apply" }).click(); + await expect(main.getByRole("alertdialog", { name: "Clear the List" })).toBeVisible(); + await expect(open).toHaveCount(1); + + // Two forms queued behind it, the first closed by the app before its turn. + await openForm(main, { title: "First Form" }); + await openForm(main, { title: "Second Form" }); + await main.evaluate(() => window.forms.at(-2).close("skipped")); + await main.keyboard.press("Escape"); + await expect(main.getByRole("dialog", { name: "Second Form" })).toBeVisible(); + await expect(main.locator("dialog.kit-form-dialog")).toHaveCount(1); + await main.keyboard.press("Escape"); + await expect(open).toHaveCount(0); + expect(await results(main)).toEqual([true, true, false, "skipped", false]); +}); + +test("kit.ui.dialog() is checked: a mistake throws before anything shows, and so does a wrong action() or close()", async ({ demo }) => { + const main = await demo.mainWindow(); + const errors = await main.evaluate(() => { + const row = () => document.createElement("div"); + const ok = () => ({ title: "Advanced Options", icon: "tune", body: [row()], actions: [{ label: "Cancel", value: false, cancel: true }, { label: "Apply", value: true, variant: "primary" }] }); + const tries = [ + null, + { ...ok(), size: "lg" }, + { ...ok(), title: "" }, + { ...ok(), icon: 3 }, + { ...ok(), body: [] }, + { ...ok(), body: "Markup" }, + { ...ok(), body: [row(), "text"] }, + { ...ok(), actions: [] }, + { ...ok(), actions: [null] }, + { ...ok(), actions: [{ label: "Apply", value: true, onClick() {} }] }, + { ...ok(), actions: [{ label: "", value: true }] }, + { ...ok(), actions: [{ label: "Apply" }] }, + { ...ok(), actions: [{ label: "No", value: false }, { label: "Also No", value: false }] }, + { ...ok(), actions: [{ label: "Apply", value: true, variant: "link" }] }, + { ...ok(), actions: [{ label: "Cancel", value: false, cancel: "yes" }] }, + { ...ok(), actions: [{ label: "Check", value: true, close: 0 }] }, + { ...ok(), actions: [{ label: "Cancel", value: false, cancel: true, close: false }] }, + { ...ok(), actions: [{ label: "Cancel", value: false, cancel: true }, { label: "Stop", value: "stop", cancel: true }] }, + { ...ok(), actions: [{ label: "Apply", value: true, variant: "primary" }, { label: "Save", value: "save", variant: "primary" }] }, + ].map((options) => { + try { + window.kit.ui.dialog(options); + return "shown"; + } catch (err) { + return err.message; + } + }); + const form = window.kit.ui.dialog(ok()); + for (const call of [() => form.action("apply"), () => form.close()]) { + try { + call(); + tries.push("done"); + } catch (err) { + tries.push(err.message); + } + } + form.close(false); + return tries; + }); + expect(errors).toEqual([ + "kit.ui.dialog(): it takes { title, icon, body, actions }.", + "kit.ui.dialog(): it has no option \"size\".", + "kit.ui.dialog(): title must be the dialog's name, in Title Case.", + "kit.ui.dialog(): icon must be a Material Icons Round glyph.", + "kit.ui.dialog(): body must be the app's own elements: an element, or a list of them.", + "kit.ui.dialog(): body must be the app's own elements: an element, or a list of them.", + "kit.ui.dialog(): body must be the app's own elements: an element, or a list of them.", + "kit.ui.dialog(): actions must be a list of one or more { label, value, variant }.", + "kit.ui.dialog(): actions[0] must be { label, value, variant }.", + "kit.ui.dialog(): actions[0] has no option \"onClick\".", + "kit.ui.dialog(): actions[0].label must be text.", + "kit.ui.dialog(): actions[0].value must be given: it's what dialog.closed resolves with.", + "kit.ui.dialog(): actions[1].value is actions[0]'s already.", + "kit.ui.dialog(): actions[0].variant must be one of primary, secondary, success, warning, danger, outline-secondary, outline-danger.", + "kit.ui.dialog(): actions[0].cancel must be true or false.", + "kit.ui.dialog(): actions[0].close must be true or false.", + "kit.ui.dialog(): actions[0] is the cancel action, which always closes: leave close out.", + "kit.ui.dialog(): only one action can be the cancel action.", + "kit.ui.dialog(): only one action can be primary.", + "kit.ui.dialog(): action() takes one of the actions' values.", + "kit.ui.dialog(): close() takes the value dialog.closed resolves with.", + ]); + await expect(main.locator("dialog[open]")).toHaveCount(0); + await expect(main.locator("dialog.kit-form-dialog")).toHaveCount(0); +}); + +test("the demo's Advanced Options: an upload's options, Apply off while a value is more than the server allows, set on its card", async ({ demo }) => { + const main = await demo.mainWindow(); + await main.getByRole("button", { name: "Queue", exact: true }).click(); + const card = main.getByRole("group", { name: "receipts.csv", exact: true }); + // Queued, so locked: its Advanced Options is off. A ready upload's opens it. + await expect(card.getByRole("button", { name: "Advanced Options" })).toBeDisabled(); + const ready = main.getByRole("group", { name: "printer-manual.pdf", exact: true }); + await ready.getByRole("button", { name: "Advanced Options" }).click(); + const dialog = main.getByRole("dialog", { name: "Advanced Options" }); + await expect(dialog.locator(".kit-dialog-text")).toHaveText("printer-manual.pdf"); + const lifetime = dialog.getByRole("spinbutton", { name: "File lifetime" }); + const downloads = dialog.getByRole("spinbutton", { name: "Max downloads" }); + await expect(lifetime).toBeFocused(); + await expect(lifetime).toHaveValue("7"); + await expect(lifetime).toHaveAccessibleDescription("The server keeps files up to 30 days."); + await expect(downloads).toHaveAccessibleDescription("The server allows up to 100 downloads."); + const apply = dialog.getByRole("button", { name: "Apply" }); + + await lifetime.fill("31"); + await expect(lifetime).toHaveAccessibleDescription("Too long: The server keeps files up to 30 days."); + await expect(apply).toBeDisabled(); + await dialog.getByRole("combobox", { name: "Unit" }).selectOption("hours"); + await expect(apply).toBeEnabled(); + await downloads.fill("0"); + await expect(downloads).toHaveAccessibleDescription("Enter a whole number from 1 to 100."); + await expect(apply).toBeDisabled(); + await downloads.press("Enter"); + await expect(dialog).toBeVisible(); + await downloads.fill("5"); + await downloads.press("Enter"); + await expect(dialog).toHaveCount(0); + // Its warning stays on its line; the focus is back on Advanced Options. + await expect(ready.locator(".kit-card-settings")).toHaveText("Not encrypted: This server is plain HTTP."); + await expect(ready.getByRole("button", { name: "Advanced Options" })).toBeFocused(); + + // An upload with no warning shows what's set on it: receipts.csv, cancelled, then tried again. + await card.getByRole("button", { name: "Cancel" }).click(); + await card.getByRole("button", { name: "Retry" }).click(); + await card.getByRole("button", { name: "Advanced Options" }).click(); + await expect(lifetime).toHaveValue("7"); + await lifetime.fill("2"); + await dialog.getByRole("button", { name: "Apply" }).click(); + await expect(dialog).toHaveCount(0); + await expect(card.locator(".kit-card-settings")).toHaveText("2 Days · 10 Downloads"); + + // Bulk Edit: the selected uploads waiting to start, from the first's options (receipts.csv's, first on + // screen), set on each. + await ready.locator(".kit-card-meta").click(); + await card.locator(".kit-card-meta").click({ modifiers: ["Control"] }); + await main.getByRole("group", { name: "clip.mp4", exact: true }).locator(".kit-card-meta").click({ modifiers: ["Control"] }); + await main.getByRole("button", { name: "Bulk Edit" }).click(); + await expect(dialog.locator(".kit-dialog-text")).toHaveText("For the 2 uploads selected."); + await expect(lifetime).toHaveValue("2"); + await expect(dialog.getByRole("combobox", { name: "Unit" })).toHaveValue("days"); + await downloads.fill("1"); + await dialog.getByRole("button", { name: "Apply" }).click(); + await expect(dialog).toHaveCount(0); + await expect(card.locator(".kit-card-settings")).toHaveText("2 Days · 1 Download"); + await expect(ready.locator(".kit-card-settings")).toHaveText("Not encrypted: This server is plain HTTP."); + + // With no upload waiting in the selection, it says so. + await main.keyboard.press("Escape"); + await main.getByRole("group", { name: "clip.mp4", exact: true }).locator(".kit-card-meta").click(); + await main.getByRole("button", { name: "Bulk Edit" }).click(); + await expect(main.locator("#toast-host .toast-note")).toHaveText(/No selected upload is waiting to start\./); + await expect(dialog).toHaveCount(0); +}); diff --git a/e2e/keyboard.spec.js b/e2e/keyboard.spec.js index 3bb4ac6..85db0d5 100644 --- a/e2e/keyboard.spec.js +++ b/e2e/keyboard.spec.js @@ -7,7 +7,8 @@ // 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. +// status-bar.spec.js. A form (kit.ui.dialog()) is opened only to walk its +// rings; its keyboard is in dialog.spec.js. const fs = require("fs"); const path = require("path"); @@ -134,6 +135,29 @@ test("the bottom bar's item comes after the view, and it and its panel's control expect(problems).toEqual([]); }); +test("a form's fields and buttons (kit.ui.dialog()) hold 3:1 in both themes, in every accent", async ({ demo }) => { + const main = await demo.mainWindow(); + await main.getByRole("button", { name: "Queue", exact: true }).click(); + const opener = main.getByRole("group", { name: "printer-manual.pdf", exact: true }).getByRole("button", { name: "Advanced Options" }); + 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 opener.click(); + await expect(main.getByRole("spinbutton", { name: "File lifetime" })).toBeFocused(); + // From the first field round to it again: the unit, the downloads, Cancel, Apply, Close. + const stops = [await focused(main), ...await walk(main, 5)]; + expect(stops.map((stop) => stop.name)).toEqual(["File lifetime", "Unit", "Max downloads", "Cancel", "Apply", "Close"]); + problems.push(...ringProblems(stops).map((line) => `${name ?? "the demo"}'s accent, ${theme}: ${line}`)); + await main.keyboard.press("Escape"); + await expect(main.locator("dialog[open]")).toHaveCount(0); + } + 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/visual.spec.js b/e2e/visual.spec.js index 0b6a219..31ddd67 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. // -// Twenty-one states, each a whole window: +// Twenty-two 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; @@ -26,11 +26,13 @@ // target's note, and the actions a selection shows); then the update dot // 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. +// with its item focused by keyboard, and with its panel open; +// - a form (kit.ui.dialog()), the demo's Advanced Options over the Queue, +// its first field focused. +// All twenty-two in the demo's accent, in the light and dark themes. The +// seven that show the most accent (the rail, Controls toggled, the Queue at +// rest and selected, the bar and its panel, the form) again in each of the +// three apps' accents (test/fixtures/accents/), in both themes. 86 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 @@ -324,12 +326,24 @@ const STATES = { await main.locator("#kit-statusbar-checks").click(); await expect(main.getByRole("dialog", { name: "Check Details" })).toBeFocused(); }, + "form-dialog": async (main) => { + // A form (kit.ui.dialog()): the demo's Advanced Options for printer-manual.pdf, over the Queue with + // nothing selected and the bar hidden again, its first field focused. + await main.keyboard.press("Escape"); + await expect(main.getByRole("dialog", { name: "Check Details" })).toBeHidden(); + await main.evaluate(() => window.demoShell.statusBar.show(false)); + await main.getByRole("button", { name: "Queue", exact: true }).click(); + await main.keyboard.press("Escape"); + await expect(main.locator("#queue .kit-card.selected")).toHaveCount(0); + await main.getByRole("group", { name: "printer-manual.pdf", exact: true }).getByRole("button", { name: "Advanced Options" }).click(); + await expect(main.getByRole("spinbutton", { name: "File lifetime" })).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", "status-bar", "status-bar-panel"]]), + ...APP_ACCENTS.map((accent) => [accent, ["controls-toggled", "rail-expanded", "cards", "cards-selected", "status-bar", "status-bar-panel", "form-dialog"]]), ]; for (const [accent, states] of PLAN) { diff --git a/e2e/visual.spec.js-snapshots/demo-dark-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/demo-dark-form-dialog-win32.png new file mode 100644 index 0000000..2952c54 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/demo-dark-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-light-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/demo-light-form-dialog-win32.png new file mode 100644 index 0000000..1c0826c Binary files /dev/null and b/e2e/visual.spec.js-snapshots/demo-light-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-dark-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/dropgate-dark-form-dialog-win32.png new file mode 100644 index 0000000..7c676c1 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/dropgate-dark-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-light-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/dropgate-light-form-dialog-win32.png new file mode 100644 index 0000000..99bdd65 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/dropgate-light-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-dark-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/file-converter-dark-form-dialog-win32.png new file mode 100644 index 0000000..b007d4d Binary files /dev/null and b/e2e/visual.spec.js-snapshots/file-converter-dark-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-light-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/file-converter-light-form-dialog-win32.png new file mode 100644 index 0000000..782ae27 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/file-converter-light-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-dark-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/media-player-dark-form-dialog-win32.png new file mode 100644 index 0000000..bdd04c5 Binary files /dev/null and b/e2e/visual.spec.js-snapshots/media-player-dark-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-light-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/media-player-light-form-dialog-win32.png new file mode 100644 index 0000000..0a2cddf Binary files /dev/null and b/e2e/visual.spec.js-snapshots/media-player-light-form-dialog-win32.png differ diff --git a/page/kit.js b/page/kit.js index 9f803b3..e732176 100644 --- a/page/kit.js +++ b/page/kit.js @@ -38,7 +38,9 @@ // header; its action shows a list under the message (what was skipped). // kit.ui.confirm({ title, body, … }) asks a question in a modal, one at a // time, and resolves with the answer; its batch form takes the answers as -// choices, with Apply to All Remaining. +// choices, with Apply to All Remaining. kit.ui.dialog({ title, icon, body, +// actions }) is a small form in the same modal, the app's own fields in it, +// waiting its turn with the prompts, and resolves with the button pressed. // kit.ui.progress(), kit.ui.actionBar() and kit.ui.dropZone() are the parts of // a section that works through a list of files: a bar (null is "not known"), // the bar under the list with its status line and its one primary action, and @@ -617,7 +619,7 @@ /** The variants a prompt's button may take: Bootstrap's, as DESIGN §6 uses them. */ const BUTTON_VARIANTS = ["primary", "secondary", "success", "warning", "danger", "outline-secondary", "outline-danger"]; - /** The prompt showing now, or last shown, so the next waits for it: one prompt at a time. */ + /** The prompt or form showing now, or last shown, so the next waits for it: one at a time. */ let prompts = Promise.resolve(); /** The prompt's dialog, built the first time one is asked, and filled for each. */ @@ -704,37 +706,21 @@ } /** - * Show one prompt, and resolve with its answer once it's closed. - * @param {ReturnType} prompt + * Show a kit dialog, a prompt's or a form's, as a modal, and wire its ways + * out, so the two can't drift: answer(value) fades it out, then closes it; + * Escape and the close button answer cancel, and so does a click on the + * backdrop where backdrop is true. Closing is what calls onClosed, with + * the answer. A modal gives the focus back to what had it once it + * closes, by itself. + * @param {HTMLDialogElement} dialog + * @param {{ closeButton: HTMLElement, cancel: any, backdrop: boolean, focus: () => HTMLElement, onClosed: (value: any) => void }} how + * @returns {(value: any) => void} answer */ - function ask(prompt) { - const parts = confirmDialog(); - const { dialog, glyph, title, closeButton, body, detail, all, allLabel, allRow, footer } = parts; - glyph.textContent = prompt.glyph; - glyph.className = `material-icons-round kit-dialog-icon${prompt.tone ? ` text-${prompt.tone}` : ""}`; - title.textContent = prompt.title; - body.textContent = prompt.body; - detail.textContent = prompt.detail ?? ""; - detail.hidden = !prompt.detail; - allRow.hidden = !prompt.applyToAll; - allLabel.textContent = prompt.applyToAll ?? ""; - all.checked = false; - delete all.dataset.kitTick; - const buttons = prompt.buttons.map(({ value, label, variant }) => { - const button = el("button", { className: `btn btn-${variant}`, text: label, attrs: { type: "button" } }); - button.addEventListener("click", () => answer(value)); - return [value, button]; - }); - footer.replaceChildren(...buttons.map(([, button]) => button)); - + function showModal(dialog, { closeButton, cancel, backdrop, focus, onClosed }) { let answered = null; let fallback = null; - let done; - const closed = new Promise((resolve) => { - done = resolve; - }); - /** Answer, and close: faded out, then closed (closing is what resolves the prompt). */ + /** Answer, and close: faded out, then closed (closing is what resolves the dialog). */ function answer(value) { if (answered !== null || !dialog.open) return; answered = { value }; @@ -755,41 +741,76 @@ // Escape: faded out like any other answer. Pressed again before it's gone, Chromium // may close the dialog itself, which onClose answers the same way. event.preventDefault(); - answer(prompt.cancel); + answer(cancel); }; const onBackdrop = (event) => { if (event.target !== dialog) return; const box = dialog.getBoundingClientRect(); const inside = event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom; - if (!inside) answer(prompt.cancel); + if (!inside) answer(cancel); }; - const onCloseButton = () => answer(prompt.cancel); + const onCloseButton = () => answer(cancel); function onClose() { - // Not left to close the next prompt, which may open in this one's place at once. + // Not left to close the next dialog, which may open in this one's place at once. clearTimeout(fallback); dialog.removeEventListener("cancel", onCancel); dialog.removeEventListener("click", onBackdrop); dialog.removeEventListener("close", onClose); closeButton.removeEventListener("click", onCloseButton); dialog.classList.remove("leaving"); - const value = answered ? answered.value : prompt.cancel; - done(prompt.batch ? { choice: value, all: all.checked } : value); + onClosed(answered ? answered.value : cancel); } dialog.addEventListener("cancel", onCancel); - dialog.addEventListener("click", onBackdrop); + if (backdrop) dialog.addEventListener("click", onBackdrop); dialog.addEventListener("close", onClose); closeButton.addEventListener("click", onCloseButton); - // A modal gives the focus back to what had it once it closes, by itself. dialog.showModal(); - buttons.find(([value]) => value === prompt.focus)[1].focus(); - return closed; + focus().focus(); + return answer; + } + + /** + * Show one prompt, and resolve with its answer once it's closed. + * @param {ReturnType} prompt + */ + function ask(prompt) { + const parts = confirmDialog(); + const { dialog, glyph, title, closeButton, body, detail, all, allLabel, allRow, footer } = parts; + glyph.textContent = prompt.glyph; + glyph.className = `material-icons-round kit-dialog-icon${prompt.tone ? ` text-${prompt.tone}` : ""}`; + title.textContent = prompt.title; + body.textContent = prompt.body; + detail.textContent = prompt.detail ?? ""; + detail.hidden = !prompt.detail; + allRow.hidden = !prompt.applyToAll; + allLabel.textContent = prompt.applyToAll ?? ""; + all.checked = false; + delete all.dataset.kitTick; + /** The prompt's answer, once it's shown. */ + let answer = null; + const buttons = prompt.buttons.map(({ value, label, variant }) => { + const button = el("button", { className: `btn btn-${variant}`, text: label, attrs: { type: "button" } }); + button.addEventListener("click", () => answer(value)); + return [value, button]; + }); + footer.replaceChildren(...buttons.map(([, button]) => button)); + + return new Promise((resolve) => { + answer = showModal(dialog, { + closeButton, + cancel: prompt.cancel, + backdrop: true, + focus: () => buttons.find(([value]) => value === prompt.focus)[1], + onClosed: (value) => resolve(prompt.batch ? { choice: value, all: all.checked } : value), + }); + }); } /** * Ask a question only the person can answer, in a modal over the page, * and resolve with the answer. Prompts are asked one at a time: one asked - * while another shows waits until that's answered. + * while another, or a form (dialog()), shows waits until that's answered. * * if (!await kit.ui.confirm({ title: "Large Frame Export", body: "This writes about 4,000 images.", * detail: "Narrow the range if that's more than you meant.", @@ -826,6 +847,164 @@ return answer; } + // -- Dialog ------------------------------------------------------------------ + + /** What kit.ui.dialog() takes, and what each of its actions takes. */ + const DIALOG_OPTIONS = ["title", "icon", "body", "actions"]; + const DIALOG_ACTION = ["label", "value", "variant", "cancel", "close"]; + + /** Each form dialog's number, for its title's id. */ + let formDialogs = 0; + + /** Check a form dialog's options, and put its actions in one shape. */ + function formOf(options) { + const fail = (message) => { + throw new Error(`kit.ui.dialog(): ${message}`); + }; + if (options === null || typeof options !== "object") fail("it takes { title, icon, body, actions }."); + const extra = Object.keys(options).find((key) => !DIALOG_OPTIONS.includes(key)); + if (extra !== undefined) fail(`it has no option "${extra}".`); + const { title, icon: glyph, body, actions } = options; + if (typeof title !== "string" || title === "") fail("title must be the dialog's name, in Title Case."); + if (typeof glyph !== "string" || glyph === "") fail("icon must be a Material Icons Round glyph."); + const parts = Array.isArray(body) ? body : [body]; + if (parts.length === 0 || !parts.every((part) => part instanceof Element)) fail("body must be the app's own elements: an element, or a list of them."); + if (!Array.isArray(actions) || actions.length === 0) fail("actions must be a list of one or more { label, value, variant }."); + const values = new Map(); + const checked = actions.map((action, i) => { + if (action === null || typeof action !== "object") fail(`actions[${i}] must be { label, value, variant }.`); + const unknown = Object.keys(action).find((key) => !DIALOG_ACTION.includes(key)); + if (unknown !== undefined) fail(`actions[${i}] has no option "${unknown}".`); + const { label, value, variant = "secondary", cancel = false, close = true } = action; + if (typeof label !== "string" || label === "") fail(`actions[${i}].label must be text.`); + if (value === undefined) fail(`actions[${i}].value must be given: it's what dialog.closed resolves with.`); + if (values.has(value)) fail(`actions[${i}].value is actions[${values.get(value)}]'s already.`); + values.set(value, i); + if (!BUTTON_VARIANTS.includes(variant)) fail(`actions[${i}].variant must be one of ${BUTTON_VARIANTS.join(", ")}.`); + if (typeof cancel !== "boolean") fail(`actions[${i}].cancel must be true or false.`); + if (typeof close !== "boolean") fail(`actions[${i}].close must be true or false.`); + if (cancel && !close) fail(`actions[${i}] is the cancel action, which always closes: leave close out.`); + return { label, value, variant, cancel, close }; + }); + if (checked.filter((action) => action.cancel).length > 1) fail("only one action can be the cancel action."); + if (checked.filter((action) => action.variant === "primary").length > 1) fail("only one action can be primary."); + return { title, glyph, body: parts, actions: checked }; + } + + /** Whether Enter in a control presses the primary button: a field typed in on one line. */ + const enterPresses = (target) => target instanceof HTMLInputElement && !NOT_TYPED.test(target.type); + + /** The controls a form dialog's focus can start on: its first field, by these. */ + const FIELDS = "input:not([type=hidden]), select, textarea"; + + /** + * A small form in a modal over the page, for an app's own options: the + * title with its glyph, the app's own elements, and its buttons. + * + * const dialog = kit.ui.dialog({ + * title: "Advanced Options", // Title Case + * icon: "tune", + * body: [nameLine, lifetimeRow, downloadsRow], // the app's own elements + * actions: [ + * { label: "Cancel", value: false, cancel: true }, + * { label: "Apply", value: true, variant: "primary" }, + * ], + * }); + * dialog.action(true).disabled = !valid; // the app turns its button on and off + * if (await dialog.closed) apply(); + * + * It's drawn and animated as confirm()'s prompt (kit-dialog), a native + * modal named by its title. The actions are left to right, each + * btn-secondary unless it names its variant; one may be primary, which + * Enter in a field presses (unless it's disabled), and one may be the + * cancel action. closed resolves with the value of the action pressed, + * or with the cancel action's for Escape or the close button (false + * with none). A click on the backdrop doesn't close it: what was typed + * isn't thrown away. An action with close: false doesn't close it either: + * the app hears it (action(value)) and calls close(value) once it's done. + * The focus starts on the first field (or the primary button, or the + * last), and goes back to what had it on close. It waits its turn with + * the kit's prompts: one prompt or form at a time. It's in the page only + * while it shows. + * @returns {{ element: HTMLDialogElement, action(value: any): HTMLButtonElement, close(value: any): void, closed: Promise }} + */ + function dialog(options) { + const form = formOf(options); + const titleId = `kit-form-dialog-${++formDialogs}-title`; + const closeButton = el("button", { className: "btn-close", attrs: { type: "button", "aria-label": "Close" } }); + const buttons = new Map(form.actions.map(({ label, value, variant, close }) => { + const button = el("button", { className: `btn btn-${variant}`, text: label, attrs: { type: "button" } }); + if (close) button.addEventListener("click", () => answer?.(value)); + return [value, button]; + })); + const element = el("dialog", { className: "kit-dialog kit-form-dialog", attrs: { "aria-modal": "true", "aria-labelledby": titleId } }, [ + el("div", { className: "kit-dialog-header" }, [ + el("h2", { className: "kit-dialog-title" }, [icon(form.glyph, "kit-dialog-icon"), el("span", { text: form.title, attrs: { id: titleId } })]), + closeButton, + ]), + el("div", { className: "kit-dialog-body" }, form.body), + el("div", { className: "kit-dialog-footer" }, [...buttons.values()]), + ]); + const cancel = form.actions.find((action) => action.cancel)?.value ?? false; + const primary = buttons.get(form.actions.find((action) => action.variant === "primary")?.value); + + element.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.isComposing || !enterPresses(event.target)) return; + event.preventDefault(); + if (primary && !primary.disabled) primary.click(); + }); + + /** Its answer while it shows; null before and after. */ + let answer = null; + let finished = false; + let resolve; + const closed = new Promise((done) => { + resolve = done; + }); + function finish(value) { + if (finished) return; + finished = true; + answer = null; + element.remove(); + resolve(value); + } + + const shown = prompts.then(() => { + // Closed by the app before its turn: it never shows. + if (finished) return; + document.body.append(element); + const visible = (control) => !control.disabled && control.getClientRects().length > 0; + answer = showModal(element, { + closeButton, + cancel, + backdrop: false, + focus: () => [...element.querySelector(".kit-dialog-body").querySelectorAll(FIELDS)].find(visible) + ?? (primary && !primary.disabled ? primary : null) + ?? [...buttons.values()].reverse().find((button) => !button.disabled) + ?? closeButton, + onClosed: finish, + }); + return closed; + }); + prompts = shown.then(() => {}, () => {}); + + return { + element, + /** The button of the action with this value, for the app to turn on and off, or to hear. */ + action(value) { + if (!buttons.has(value)) throw new Error("kit.ui.dialog(): action() takes one of the actions' values."); + return buttons.get(value); + }, + /** Close it with this value, faded out as a button does; before its turn, it never shows. */ + close(value) { + if (value === undefined) throw new Error("kit.ui.dialog(): close() takes the value dialog.closed resolves with."); + if (answer) answer(value); + else finish(value); + }, + closed, + }; + } + /** The Credits tab, from app:get-info: the logo, name and version, the credit lines, then donating and the source. */ function fillCredits(pane, { name, version, repository, credits }, logo) { const heading = el("div", { className: "credits-heading" }, [ @@ -2297,7 +2476,7 @@ }, true); window.kit = { - ui: { mountShell, toast, confirm, progress, actionBar, dropZone, busy, statusLine, card, cardGrid, bulkBar, switchField, stepSlider }, + ui: { mountShell, toast, confirm, dialog, 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 432d2e3..3f353dd 100644 --- a/test/kit-css.test.js +++ b/test/kit-css.test.js @@ -322,3 +322,8 @@ test("the Settings tabs sit on a thin line, with a bar in the accent's fill that assert.equal(bar.transform, "translateX(var(--indicator-x, 0))"); assert.equal(bar.transition.replace(/\s+/g, " "), "transform var(--dur-default) var(--ease-standard), width var(--dur-default) var(--ease-standard)"); }); + +test("a form dialog is a prompt's dialog with its rows apart, and the house's help line keeps its height", () => { + assert.deepEqual(rule(".kit-form-dialog .kit-dialog-body"), { display: "flex", "flex-direction": "column", gap: "0.75rem" }); + assert.deepEqual(rule(".form-text.reserved"), { "min-height": "1.5em" }); +});