Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion css/kit.css
Original file line number Diff line number Diff line change
Expand Up @@ -1594,7 +1594,8 @@ a:hover {
}

/* -- Prompts --------------------------------------------------------------------
kit.ui.confirm(): a native modal <dialog>, 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 <dialog>, 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
Expand Down Expand Up @@ -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 {
Expand Down
118 changes: 116 additions & 2 deletions demo/src/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
],
});
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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
Expand Down
90 changes: 87 additions & 3 deletions docs/page-components.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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 `<dialog>` (`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
Expand Down Expand Up @@ -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 `<dialog>` 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 `<dialog>`.
- **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:
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading