staffa 0.18.2 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -220,6 +220,17 @@ Return the paths shallowest first, or nothing to fall back to the parent-path wa
220
220
  - Aberdeen's own `route.go()` works, but builds the whole stack from the path; prefer the stack's methods. A guard your app registered with `route.setGuard` keeps working — Staffa registers none of its own.
221
221
  - Deep links need your static server to serve the app for unknown paths (the usual SPA fallback; `-P` for `http-server`, as in the demo command below).
222
222
 
223
+ ### Waiting
224
+
225
+ A `click` handler — or a form's `submit` — that returns a promise puts its button to work until that promise settles: a spinner rides after the label, and clicks (and Enter) bounce off, so a slow save can't be started twice. There is nothing to wire up:
226
+
227
+ ```ts
228
+ S.button({ content: "Save", click: () => api.save($user) });
229
+ S.form({ submit: (data) => api.save(data), content: drawFields, actions: () => S.button({ content: "Save", type: "submit" }) });
230
+ ```
231
+
232
+ A form marks its own submit buttons, wherever they sit, and a handler that awaits a `confirm()` waits with it. For a wait that isn't a promise, put a control in the same state yourself with `attrs: ".s-busy"`.
233
+
223
234
  ## Components
224
235
 
225
236
  Every option of every component is documented in TSDoc on its `…Options` interface. Options share naming conventions: `attrs` (outermost element), `contentAttrs` (the children-holding element), `inputAttrs` (the form control) and `<region>Attrs` (`headerAttrs`, `footerAttrs`, …) — all Aberdeen attr/style strings, applied last so they can override. Form components consistently support `label`, `help`, `error`, `disabled`, `required` and `name`, and two-way binding through `bind: A.ref($obj, "key")`.
@@ -229,6 +240,15 @@ Every option of every component is documented in TSDoc on its `…Options` inter
229
240
  - **Actions**: `button`, `iconButton`, `buttonGroup`, `buttonChooser`.
230
241
  - **Overlays & feedback**: `dialog` (+ `alert`, `confirm`, `prompt`), `menu`, `menuButton`, `showFloatingMenu`, `addContextMenu`, `toast`, `addTooltip`.
231
242
 
243
+ Buttons, icon buttons and menu items carry a `tooltip` option of their own, so most tips need no `addTooltip` call:
244
+
245
+ ```ts
246
+ S.button({ content: "Publish", tooltip: "Not until the draft validates", disabled: true });
247
+ S.iconButton({ icon: trash2, ariaLabel: "Delete" }); // says "Delete" on hover
248
+ ```
249
+
250
+ An icon button tips its `ariaLabel` unless given a `tooltip` of its own (`tooltip: false` for neither), a `key` is appended to whatever the tip says, and — unlike a tooltip on a plain disabled `<button>`, which the browser gives no hover events — these show while disabled, which is where a tooltip earns its keep.
251
+
232
252
  `src/index.ts` is the authoritative list of exports.
233
253
 
234
254
  ### Keyboard shortcuts
@@ -31,6 +31,10 @@ export interface AutocompleteOptions extends FieldOptions {
31
31
  * optional free-text entry, and full keyboard control (arrows, enter, escape,
32
32
  * backspace-to-remove). Implements the ARIA combobox/listbox pattern.
33
33
  *
34
+ * The suggestion list is portalled to `document.body`, so a dialog or a
35
+ * scrolling column can neither clip it nor grow a scrollbar around it. It hangs
36
+ * off whichever side of the field has the room, and follows it as things move.
37
+ *
34
38
  * @example
35
39
  * ```ts
36
40
  * // Single select from a fixed list
@@ -1,9 +1,8 @@
1
1
  import A from "aberdeen";
2
- import { uniqueId } from "../core.js";
2
+ import { followAnchor, mountPortal, uniqueId } from "../core.js";
3
3
  import { drawField } from "./field.js";
4
4
  A.insertGlobalCss({
5
5
  ".s-ac": {
6
- "&": "position:relative",
7
6
  // Same light inset field as `.s-input` (see field.ts), derived from the surface.
8
7
  "> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
9
8
  "> .s-control:hover": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
@@ -13,16 +12,56 @@ A.insertGlobalCss({
13
12
  ".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
14
13
  ".s-chip > button:hover": "fg:$s-text background:$s-faint",
15
14
  "input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
16
- // Background, border, radius and elevation come from the popup's
17
- // `.s-s.neutral.shadow` surface (see below).
18
- "> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0",
19
- "> .s-menu li": "margin:0",
15
+ },
16
+ // Background, border, radius and elevation come from the `.s-s.neutral.shadow`
17
+ // surface it carries; `place()` below sizes and positions it. Both of its
18
+ // classes are named here: standing in `<body>` rather than inside the field,
19
+ // it would otherwise lose to theme.ts's flow margins on `ul` and `li`.
20
+ ".s-ac-menu.s-s": {
21
+ "&": "position:fixed max-height:15rem overflow-y:auto list-style:none p:$1 margin:0",
22
+ li: "margin:0",
20
23
  ".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
21
24
  ".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-text 10%, transparent);",
22
25
  ".s-add": "fg:$s-accent font-style:italic",
23
26
  ".s-empty": "padding: 0.45em 0.6em; fg:$s-muted",
24
27
  },
25
28
  });
29
+ // Only one list is up at a time — it belongs to whichever field has focus — so
30
+ // one portal at the end of <body> serves them all. Drawn inside the field, the
31
+ // list would be clipped by a dialog or a scrolling column, and would stretch
32
+ // that scroller's bar to reach it.
33
+ const $popup = A.proxy({ cur: null });
34
+ /** Hang the list under the field — or over it, when that's where the room is. */
35
+ function place(el, r) {
36
+ const gap = 4, edge = 8;
37
+ // Measured at the stylesheet's own cap, so the flip is decided on the height
38
+ // the list wants, not on whatever the last placement clamped it to.
39
+ el.style.maxHeight = "";
40
+ const want = el.offsetHeight;
41
+ const below = window.innerHeight - r.bottom - gap - edge;
42
+ const above = r.top - gap - edge;
43
+ const up = want > below && above > below;
44
+ el.style.left = `${r.left}px`;
45
+ el.style.width = `${r.width}px`;
46
+ el.style.maxHeight = `${Math.min(want, Math.max(up ? above : below, 60))}px`;
47
+ el.style.top = up ? "auto" : `${r.bottom + gap}px`;
48
+ el.style.bottom = up ? `${window.innerHeight - r.top + gap}px` : "auto";
49
+ }
50
+ mountPortal(() => {
51
+ const p = $popup.cur;
52
+ if (!p)
53
+ return;
54
+ let sizeChanged;
55
+ const el = A("ul.s-ac-menu.s-s.neutral.shadow role=listbox", `id=${p.id} z-index:${p.zIndex}`, () => {
56
+ // A press in the list must not blur the field: the click that follows is
57
+ // what commits, and dragging the scrollbar has to keep it open too.
58
+ A("mousedown=", (e) => e.preventDefault());
59
+ p.draw();
60
+ // Re-run as you type, with the rows; the list's height changes with them.
61
+ sizeChanged?.();
62
+ });
63
+ sizeChanged = followAnchor(p.anchor, (r) => place(el, r));
64
+ });
26
65
  function normOption(o) {
27
66
  return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
28
67
  }
@@ -31,6 +70,10 @@ function normOption(o) {
31
70
  * optional free-text entry, and full keyboard control (arrows, enter, escape,
32
71
  * backspace-to-remove). Implements the ARIA combobox/listbox pattern.
33
72
  *
73
+ * The suggestion list is portalled to `document.body`, so a dialog or a
74
+ * scrolling column can neither clip it nor grow a scrollbar around it. It hangs
75
+ * off whichever side of the field has the room, and follows it as things move.
76
+ *
34
77
  * @example
35
78
  * ```ts
36
79
  * // Single select from a fixed list
@@ -103,11 +146,36 @@ export function autocomplete(opts) {
103
146
  const arr = opts.bind.value ?? [];
104
147
  opts.bind.value = arr.filter((v) => v !== value);
105
148
  };
149
+ let inputEl;
150
+ /** The list's rows. Runs in the body portal, on this field's state. */
151
+ const drawList = () => {
152
+ const list = filtered();
153
+ const q = $st.query.trim();
154
+ const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
155
+ list.forEach((option, i) => {
156
+ A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
157
+ A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
158
+ A("#", option.label);
159
+ A("click=", () => commit(option.value, inputEl));
160
+ A("mousemove=", () => {
161
+ $st.active = i;
162
+ });
163
+ });
164
+ });
165
+ if (showAdd) {
166
+ A("li.s-option.s-add role=option", () => {
167
+ A("#", `Add "${q}"`);
168
+ A("click=", () => commit(q, inputEl));
169
+ });
170
+ }
171
+ if (list.length === 0 && !showAdd) {
172
+ A("li.s-empty #No matches");
173
+ }
174
+ };
106
175
  drawField(opts, (id, isInvalid) => {
107
176
  A("div.s-ac", opts.inputAttrs, () => {
108
177
  A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
109
- let inputEl;
110
- A("div.s-control", () => {
178
+ const controlEl = A("div.s-control", () => {
111
179
  A("click=", () => inputEl?.focus());
112
180
  // Chips for multi-select.
113
181
  A(() => {
@@ -156,36 +224,16 @@ export function autocomplete(opts) {
156
224
  A("keydown=", (e) => onKey(e, inputEl));
157
225
  });
158
226
  });
159
- // The suggestions popup.
227
+ // Hand the list to the portal for as long as it is up. Its layer clears
228
+ // the dialog the field sits in, but stays under one that may open over
229
+ // it — a field on the page can't paint across a modal.
160
230
  A(() => {
161
231
  if (!$st.open)
162
232
  return;
163
- const list = filtered();
164
- const q = $st.query.trim();
165
- const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
166
- A("ul.s-menu.s-s.neutral.shadow role=listbox", `id=${menuId}`, () => {
167
- list.forEach((option, i) => {
168
- A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
169
- A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
170
- A("#", option.label);
171
- A("mousedown=", (e) => e.preventDefault());
172
- A("click=", () => commit(option.value, inputEl));
173
- A("mousemove=", () => {
174
- $st.active = i;
175
- });
176
- });
177
- });
178
- if (showAdd) {
179
- A("li.s-option.s-add role=option", () => {
180
- A("#", `Add "${q}"`);
181
- A("mousedown=", (e) => e.preventDefault());
182
- A("click=", () => commit(q, inputEl));
183
- });
184
- }
185
- if (list.length === 0 && !showAdd) {
186
- A("li.s-empty #No matches");
187
- }
188
- });
233
+ const zIndex = controlEl.closest(".s-dialog") ? 350 : 150;
234
+ $popup.cur = { id: menuId, anchor: controlEl, zIndex, draw: drawList };
235
+ A.clean(() => { if ($popup.cur?.id === menuId)
236
+ $popup.cur = null; });
189
237
  });
190
238
  // Hidden inputs so the selection participates in native FormData.
191
239
  A(() => {
@@ -5,14 +5,28 @@ export interface IconButtonOptions {
5
5
  icon: Slot;
6
6
  /** What it does, for screen readers. Required: there is no visible text to read. */
7
7
  ariaLabel: string;
8
- /** Click handler. */
9
- click?: (event: Event) => void;
8
+ /**
9
+ * Click handler. Return a promise and the glyph becomes a spinner until it
10
+ * settles, with further clicks bouncing off — see {@link ButtonOptions.click}.
11
+ */
12
+ click?: (event: Event) => unknown;
10
13
  /**
11
14
  * A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
12
- * The tooltip shows it after the `ariaLabel` (a glyph is worth naming there
13
- * anyway), and the `?` overview lists it under that label too.
15
+ * The tooltip shows it after the button's `tooltip` (or, failing that, its
16
+ * `ariaLabel` — a glyph is worth naming there anyway), and the `?` overview
17
+ * lists it under that label too.
14
18
  */
15
19
  key?: string;
20
+ /**
21
+ * A tooltip, shown on hover and keyboard focus; a string renders as rich
22
+ * text. Defaults to the `ariaLabel`, so an icon button says what it does
23
+ * without being told twice — pass this only to say something longer or
24
+ * different, or `false` for no tooltip at all. A `key` is appended to it.
25
+ *
26
+ * Works on a disabled button too, which is where a tooltip earns its keep:
27
+ * it is the only room there is to say why.
28
+ */
29
+ tooltip?: Slot | false;
16
30
  /** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
17
31
  href?: string;
18
32
  /** Disables it. */
@@ -30,8 +44,18 @@ export interface ButtonOptions {
30
44
  content?: Slot;
31
45
  /** Leading icon/adornment, drawn before the label. */
32
46
  icon?: Slot;
33
- /** Click handler. */
34
- click?: (event: Event) => void;
47
+ /**
48
+ * Click handler.
49
+ *
50
+ * **Return a promise and the button goes *busy* until it settles**: a spinner
51
+ * rides after the label, and clicks (and keypresses) bounce off, so a slow
52
+ * save can't be started twice. Nothing to wire up — `click: () => save()` on
53
+ * an async `save` is the whole thing, and a handler awaiting a
54
+ * {@link confirm} counts: the button waits for the answer with everything
55
+ * else. For a wait that isn't a promise, put the button in the same state
56
+ * yourself with `attrs: ".s-busy"`.
57
+ */
58
+ click?: (event: Event) => unknown;
35
59
  /** Disables the button. */
36
60
  disabled?: boolean;
37
61
  /** Native button behaviour. Defaults to `"button"`. */
@@ -50,6 +74,16 @@ export interface ButtonOptions {
50
74
  * its `ariaLabel`).
51
75
  */
52
76
  key?: string;
77
+ /**
78
+ * A tooltip, shown on hover and keyboard focus. A string renders as rich
79
+ * text, a function draws its own markup. A `key` is appended to it, behind
80
+ * a `·`. Defaults to the `ariaLabel` of a button that has one (an icon-only
81
+ * button, that is); pass `false` for no tooltip at all.
82
+ *
83
+ * Works on a disabled button too, which is where a tooltip earns its keep:
84
+ * it is the only room there is to say why.
85
+ */
86
+ tooltip?: Slot | false;
53
87
  /**
54
88
  * Aberdeen attr/style string applied to the button. A button is a surface, so
55
89
  * pass surface modifier classes here to restyle it, e.g. `".danger"`,
@@ -90,16 +124,18 @@ export declare function iconButton(opts: IconButtonOptions): void;
90
124
  * content.
91
125
  *
92
126
  * **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
93
- * startup) for SPA-style navigation without manual click handlers:
127
+ * startup) for SPA-style navigation without manual click handlers — a routed
128
+ * {@link main} already handles link clicks itself, so don't call it there:
94
129
  * ```ts
95
- * import {interceptLinks} from from "aberdeen/route";
130
+ * import {interceptLinks} from "aberdeen/route";
96
131
  * interceptLinks(); // once at root
97
132
  * S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
98
133
  * ```
99
134
  *
100
135
  * @example
101
136
  * ```ts
102
- * S.button({ content: "Save", click: S.alert("Saved.") });
137
+ * S.button({ content: "Save", click: () => save() }); // async: spins while it saves
138
+ * S.button({ content: "About", click: () => S.alert("Staffa.") });
103
139
  * S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
104
140
  * S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
105
141
  * S.button("Cancel"); // shorthand for { content: "Cancel" }
@@ -12,15 +12,18 @@ A.insertGlobalCss({
12
12
  "transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
13
13
  // Focus ring via `outline`, not box-shadow: `.no-shadow` hard-clears box-shadow.
14
14
  "&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
15
- "&:hover": "filter: brightness(1.06)",
16
- "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
15
+ "&:hover:not([aria-disabled=true])": "filter: brightness(1.06)",
16
+ "&.tonal:hover:not([aria-disabled=true]), &.outlined:hover:not([aria-disabled=true])": "background: color-mix(in srgb, $s-bg 24%, transparent);",
17
17
  // A `.neutral` button is already near-white, so it darkens toward its ink
18
18
  // instead of brightening.
19
- "&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
19
+ "&.neutral:hover:not([aria-disabled=true])": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
20
20
  // The button sizes its glyph rather than trusting the caller: only a rule here
21
21
  // makes every icon in a row match. In `em`, so `.small`/`.large` scale it.
22
22
  "> svg": "width:1.25em height:1.25em",
23
- "&:active:not(:disabled)": "transform: translateY(1px)",
23
+ "&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
24
+ // A disabled button that has something to say still takes hover, so its
25
+ // tooltip can say it (see `applyActionBehavior`); theme.ts stops the rest.
26
+ "&[aria-disabled=true]": "pointer-events:auto cursor:not-allowed",
24
27
  // Also inherited from a `.small`/`.large` parent (e.g. a buttonGroup), so a
25
28
  // container can size all its buttons at once.
26
29
  "&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
@@ -39,10 +42,26 @@ A.insertGlobalCss({
39
42
  "> svg": "width:1.25em height:1.25em",
40
43
  "&:hover:not(:disabled):not([aria-disabled=true])": "fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
41
44
  "&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
45
+ // A disabled button that has something to say still takes hover, so its
46
+ // tooltip can say it (see `applyActionBehavior`); theme.ts stops the rest.
47
+ "&[aria-disabled=true]": "pointer-events:auto cursor:not-allowed",
42
48
  // The glyph rides the font size, so it scales with the hit area.
43
49
  "&.small, .small > &": "width:1.6rem height:1.6rem font-size:0.8rem",
44
50
  "&.large, .large > &": "width:2.4rem height:2.4rem font-size:1.2rem",
45
51
  },
52
+ // ── Busy ──────────────────────────────────────────────────────────────────
53
+ // Raised by a promise-returning `click`, by a submitting `S.form` on its own
54
+ // submit buttons, or by a caller who has some other wait to show. The spinner
55
+ // is a pseudo-element, so nothing has to be redrawn to start or stop it, and
56
+ // the flex `gap` spaces it like any other child.
57
+ ".s-btn.s-busy, .s-icon-btn.s-busy, .s-busy .s-btn[type=submit]": "pointer-events:none cursor:progress",
58
+ ".s-btn.s-busy::after, .s-icon-btn.s-busy::after, .s-busy .s-btn[type=submit]::after": "content:'' flex-shrink:0 width:1em height:1em r:50% " +
59
+ "border: 2px solid currentColor; border-top-color: transparent; " +
60
+ "animation: s-spin 0.7s linear infinite;",
61
+ // The glyph *is* an icon button, so the spinner takes its place rather than
62
+ // crowding in beside it.
63
+ ".s-icon-btn.s-busy > svg": "display:none",
64
+ "@keyframes s-spin": { to: "transform: rotate(360deg)" },
46
65
  });
47
66
  /**
48
67
  * A bare glyph in a square hit area — no fill, no border, just ink that lifts on
@@ -65,11 +84,15 @@ A.insertGlobalCss({
65
84
  */
66
85
  export function iconButton(opts) {
67
86
  const tag = opts.href != null ? "a" : "button";
87
+ // A glyph says nothing on its own, so the name it carries for screen readers is
88
+ // the tip too, unless the caller has something better (or `false`) to say.
89
+ const tip = opts.tooltip === false ? undefined : opts.tooltip ?? opts.ariaLabel;
68
90
  A(`${tag}.s-icon-btn`, opts.attrs, () => {
69
- applyActionBehavior(opts);
91
+ applyActionBehavior(opts, tip != null);
70
92
  A("aria-label=", opts.ariaLabel);
71
- if (opts.key)
72
- applyKey(opts.key, opts.ariaLabel, undefined, opts.disabled);
93
+ // Before the glyph, so a tooltip the caller adds in there is the later of
94
+ // the two and wins the hover.
95
+ applyTooltipAndKey(tip, opts.key, opts.ariaLabel, opts.disabled);
73
96
  drawSlot(opts.icon);
74
97
  });
75
98
  }
@@ -79,7 +102,7 @@ export function iconButton(opts) {
79
102
  * anchor without one is out of the tab order and follows nothing, which is what
80
103
  * makes it as disabled as a `<button>`'s real `disabled` attribute.
81
104
  */
82
- function applyActionBehavior(o) {
105
+ function applyActionBehavior(o, hasTooltip = false) {
83
106
  if (o.href != null) {
84
107
  A("role=button");
85
108
  if (o.disabled)
@@ -87,35 +110,84 @@ function applyActionBehavior(o) {
87
110
  else
88
111
  A("href=", o.href);
89
112
  }
113
+ else if (o.disabled && hasTooltip) {
114
+ // A natively `disabled` button fires no mouse events at all, so a tooltip on
115
+ // one — usually the one saying *why* it is disabled — would never show. Say
116
+ // it the ARIA way instead: the same dimmed, unclickable, out-of-the-tab-order
117
+ // button (theme.ts dims it, the CSS above keeps only hover alive), but one
118
+ // the pointer can still reach. Nothing can activate it: the `click` handler
119
+ // is skipped below, and this one stops a `type=submit` from reaching its form
120
+ // through a stray click or Enter.
121
+ A("type=", o.type ?? "button");
122
+ A("aria-disabled=true tabindex=-1");
123
+ A("click=", (event) => { event.preventDefault(); event.stopPropagation(); });
124
+ }
90
125
  else {
91
126
  A("type=", o.type ?? "button");
92
127
  if (o.disabled)
93
128
  A("disabled=true");
94
129
  }
95
130
  if (o.click && !o.disabled)
96
- A("click=", o.click);
131
+ applyClick(o.click);
132
+ }
133
+ /**
134
+ * Wire a click handler that may be asynchronous: while the promise it returned
135
+ * is pending, the element wears `.s-busy` (spinner, no pointer events) and
136
+ * further clicks are ignored — the double-submit guard every save button needs.
137
+ *
138
+ * The rejection is rethrown from a promise nobody handles, so an error in the
139
+ * handler still reaches the console exactly as it would without us.
140
+ */
141
+ function applyClick(click) {
142
+ const $busy = A.proxy({ value: false });
143
+ // Own scope: raising and dropping the class must not recreate the element,
144
+ // which would drop keyboard focus mid-click.
145
+ A(() => {
146
+ if ($busy.value)
147
+ A(".s-busy aria-busy=true");
148
+ });
149
+ A("click=", (event) => {
150
+ // A keypress on a focused button still gets here while `pointer-events:none`
151
+ // holds the mouse off, so the guard is what actually stops the second call.
152
+ if ($busy.value)
153
+ return;
154
+ const result = click(event);
155
+ if (!result || typeof result.then !== "function")
156
+ return;
157
+ $busy.value = true;
158
+ const done = () => { $busy.value = false; };
159
+ Promise.resolve(result).then(done, (err) => { done(); throw err; });
160
+ });
97
161
  }
98
162
  /**
99
- * The shortcut plumbing {@link button} and {@link iconButton} share: bind it,
100
- * announce it as `aria-keyshortcuts`, and hint at it in a tooltip the only
101
- * place a button can say what its key is without shouting it beside the label.
163
+ * The tooltip and shortcut plumbing {@link button} and {@link iconButton} share:
164
+ * show the tip, with the key appended the only place a button can say what its
165
+ * key is without shouting it beside the label — then bind that key and announce
166
+ * it as `aria-keyshortcuts`.
102
167
  *
103
168
  * Pressing it clicks the element rather than calling `click` directly, so a
104
169
  * `type=submit` still submits its form and an `href` still navigates. Call this
105
170
  * inside the button's own element scope, whose element it takes and whose life
106
171
  * the binding follows.
107
172
  */
108
- function applyKey(key, label, content, disabled) {
109
- const el = A();
110
- const tip = label ? `${label} · ${formatKey(key)}` : formatKey(key);
111
- // A draw function, not a string: a key like `*` is markup to rich text.
112
- addTooltip({ tip: () => A("#", tip) });
173
+ function applyTooltipAndKey(tip, key, label, disabled) {
174
+ if (tip != null || key) {
175
+ addTooltip({
176
+ tip: () => {
177
+ drawSlot(tip);
178
+ // A draw function, not a string: a key like `*` is markup to rich text.
179
+ if (key)
180
+ A("#", tip == null ? formatKey(key) : ` · ${formatKey(key)}`);
181
+ },
182
+ });
183
+ }
113
184
  // Bound only while it can be pressed — a disabled button would otherwise
114
185
  // swallow the combination rather than leave it to whoever else wants it. The
115
186
  // overview names it by its visible text, or its aria label failing that.
116
- if (!disabled) {
187
+ if (key && !disabled) {
188
+ const el = A();
117
189
  A("aria-keyshortcuts=", formatKey(key, true));
118
- bindKey(key, typeof content === "string" ? content : label, () => el.click());
190
+ bindKey(key, label, () => el.click());
119
191
  }
120
192
  }
121
193
  /**
@@ -126,16 +198,18 @@ function applyKey(key, label, content, disabled) {
126
198
  * content.
127
199
  *
128
200
  * **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
129
- * startup) for SPA-style navigation without manual click handlers:
201
+ * startup) for SPA-style navigation without manual click handlers — a routed
202
+ * {@link main} already handles link clicks itself, so don't call it there:
130
203
  * ```ts
131
- * import {interceptLinks} from from "aberdeen/route";
204
+ * import {interceptLinks} from "aberdeen/route";
132
205
  * interceptLinks(); // once at root
133
206
  * S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
134
207
  * ```
135
208
  *
136
209
  * @example
137
210
  * ```ts
138
- * S.button({ content: "Save", click: S.alert("Saved.") });
211
+ * S.button({ content: "Save", click: () => save() }); // async: spins while it saves
212
+ * S.button({ content: "About", click: () => S.alert("Staffa.") });
139
213
  * S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
140
214
  * S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
141
215
  * S.button("Cancel"); // shorthand for { content: "Cancel" }
@@ -145,16 +219,18 @@ function applyKey(key, label, content, disabled) {
145
219
  export function button(opts = {}) {
146
220
  const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
147
221
  const tag = o.href != null ? "a" : "button";
222
+ // An `ariaLabel` means an icon-only button, whose name is worth showing to the
223
+ // sighted too; anything else says nothing until asked.
224
+ const tip = o.tooltip === false ? undefined : o.tooltip ?? o.ariaLabel;
148
225
  // A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
149
226
  // detection here: `attrs` just names another role or variant.
150
227
  A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
151
- applyActionBehavior(o);
228
+ applyActionBehavior(o, tip != null);
152
229
  if (o.ariaLabel)
153
230
  A("aria-label=", o.ariaLabel);
154
231
  // Before the content, so a tooltip the caller adds in there is the later of
155
232
  // the two and wins the hover.
156
- if (o.key)
157
- applyKey(o.key, o.ariaLabel, o.content, o.disabled);
233
+ applyTooltipAndKey(tip, o.key, typeof o.content === "string" ? o.content : o.ariaLabel, o.disabled);
158
234
  drawSlot(o.icon);
159
235
  drawSlot(o.content);
160
236
  });
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { drawSlot, mountPortal, focusFirst } from "../core.js";
2
+ import { drawSlot, mountPortal, focusFirst, focusables } from "../core.js";
3
3
  import { button } from "./button.js";
4
4
  import { buttonGroup } from "./buttonGroup.js";
5
5
  import { textline } from "./textline.js";
@@ -58,12 +58,23 @@ mountPortal(() => {
58
58
  if (opts.allowCancel !== false)
59
59
  close();
60
60
  });
61
- const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow create=hidden destroy=hidden", opts.attrs, () => {
61
+ // Derived from the dialog's own key rather than a second counter: there is
62
+ // exactly one of these per dialog, for as long as the dialog exists.
63
+ const labelId = `s-dialog-title-${dialogId}`;
64
+ const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow role=dialog create=hidden destroy=hidden", opts.attrs, () => {
62
65
  // A modal owns the keyboard: claiming makes the shortcuts drawn inside
63
- // (the content below included) register here and silences the rest. The
64
- // `?` overview passes `keyboardTransparent` to leave them all working.
65
- if (!opts.keyboardTransparent)
66
+ // (the content below included) register here and silences the rest;
67
+ // `aria-modal` takes the page behind out of the screen reader's reading
68
+ // order, the way the backdrop takes it out of the pointer's reach; and
69
+ // Tab is held inside, so it can't wander off into what it is covering.
70
+ // The `?` overview passes `keyboardTransparent`: an overlay that informs
71
+ // rather than interrupts claims none of this.
72
+ if (!opts.keyboardTransparent) {
73
+ const el = A();
66
74
  A.clean(claimKeyboard());
75
+ A("aria-modal=true");
76
+ A("keydown=", (event) => trapTab(el, event));
77
+ }
67
78
  // Esc closes the dialog — or, while `allowCancel` forbids it, is
68
79
  // swallowed by the no-op press, so nothing below acts on it either.
69
80
  // Anchored at whoever owned the keyboard as this dialog opened —
@@ -76,7 +87,13 @@ mountPortal(() => {
76
87
  });
77
88
  A(() => {
78
89
  if (opts.header != null) {
79
- A("header.s-s.neutral", opts.headerAttrs, () => drawSlot(opts.header));
90
+ // The header names the dialog to assistive tech as well as visually:
91
+ // it is what a screen reader reads out as focus enters, before the
92
+ // control it lands on. Set from this scope (so on the dialog, and
93
+ // withdrawn with the header), rather than pointing at an id that
94
+ // isn't there.
95
+ A("aria-labelledby=", labelId);
96
+ A("header.s-s.neutral id=", labelId, opts.headerAttrs, () => drawSlot(opts.header));
80
97
  }
81
98
  });
82
99
  A("div", opts.contentAttrs, () => {
@@ -94,6 +111,23 @@ mountPortal(() => {
94
111
  focusFirst(dialogEl); });
95
112
  });
96
113
  });
114
+ /**
115
+ * Keep Tab inside a modal dialog: from its last focusable element Tab wraps
116
+ * around to the first, and Shift+Tab from the first to the last. Without this,
117
+ * a couple of Tabs walk out of the dialog and into the page it is covering —
118
+ * which the backdrop makes invisible but not unreachable, so a keyboard user
119
+ * ends up typing into something they can't see.
120
+ */
121
+ function trapTab(dialogEl, event) {
122
+ if (event.key !== "Tab" || event.altKey || event.ctrlKey || event.metaKey)
123
+ return;
124
+ const items = focusables(dialogEl);
125
+ const edge = event.shiftKey ? items[0] : items[items.length - 1];
126
+ if (!edge || document.activeElement !== edge)
127
+ return;
128
+ event.preventDefault();
129
+ (event.shiftKey ? items[items.length - 1] : items[0]).focus();
130
+ }
97
131
  /**
98
132
  * A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
99
133
  * that fades in and out. Returns a `Promise<void>` that resolves when the dialog
@@ -5,8 +5,12 @@ export interface FormOptions extends ContentOptions {
5
5
  * Submit handler. Called with collected form data (keyed by each field's
6
6
  * `name`) and the original event. `preventDefault()` is already called.
7
7
  * Multi-value fields (e.g. multi-select) produce a `string[]`.
8
+ *
9
+ * **Return a promise and the form goes *busy* until it settles**: its submit
10
+ * buttons show a spinner and stop responding, and a second submit (Enter
11
+ * included) is ignored — so a slow save runs once, however impatient the user.
8
12
  */
9
- submit?: (data: Record<string, string | string[]>, event: SubmitEvent) => void;
13
+ submit?: (data: Record<string, string | string[]>, event: SubmitEvent) => unknown;
10
14
  /**
11
15
  * Layout of fields. `"stacked"` (default) is a single column; `"grid"` packs
12
16
  * fields into a responsive multi-column grid. A field can span the full grid
@@ -30,21 +30,36 @@ A.insertGlobalCss({
30
30
  */
31
31
  export function form(opts = {}) {
32
32
  const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
33
+ // Raised while an asynchronous `submit` is still running; the CSS in button.ts
34
+ // turns the form's submit buttons into spinners off it.
35
+ const $busy = A.proxy({ value: false });
33
36
  A(`form.s-form`, o.attrs, () => {
34
37
  // Own scope, so a layout change doesn't recreate the fields (losing focus/input state).
35
38
  A(() => {
36
39
  A(".grid=", o.layout === 'grid');
37
40
  });
41
+ // Likewise: going busy must not redraw the fields being submitted.
42
+ A(() => {
43
+ if ($busy.value)
44
+ A(".s-busy aria-busy=true");
45
+ });
38
46
  A("submit=", (event) => {
39
47
  event.preventDefault();
40
- if (o.submit) {
48
+ if (o.submit && !$busy.value) {
41
49
  const fd = new FormData(event.target);
42
50
  const data = {};
43
51
  for (const key of new Set(fd.keys())) {
44
52
  const vals = fd.getAll(key);
45
53
  data[key] = vals.length === 1 ? vals[0] : vals;
46
54
  }
47
- o.submit(data, event);
55
+ const result = o.submit(data, event);
56
+ if (result && typeof result.then === "function") {
57
+ $busy.value = true;
58
+ const done = () => { $busy.value = false; };
59
+ // Rethrown from a promise nobody handles, so the error still
60
+ // reaches the console the way an unawaited one would.
61
+ Promise.resolve(result).then(done, (err) => { done(); throw err; });
62
+ }
48
63
  }
49
64
  });
50
65
  drawSlot(o.content);