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