staffa 0.19.0 → 0.22.0

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/SKILL.md CHANGED
@@ -249,10 +249,20 @@ Buttons, icon buttons and menu items carry a `tooltip` option of their own, so m
249
249
 
250
250
  ```ts
251
251
  S.button({ content: "Publish", tooltip: "Not until the draft validates", disabled: true });
252
- S.iconButton({ icon: trash2, ariaLabel: "Delete" }); // says "Delete" on hover
252
+ S.iconButton({ icon: trash2, tooltip: "Delete" }); // says "Delete" on hover, and to screen readers
253
253
  ```
254
254
 
255
- 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.
255
+ A tooltip appears only where one is asked for; on an icon button, a string `tooltip` doubles as the `ariaLabel` when that is left out, so naming a glyph takes one option rather than two. A `key` is appended to whatever the tip says (`tooltip: false` keeps even that quiet), 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.
256
+
257
+ `S.textline` and `S.textarea` take a `prefix` and a `suffix` slot that put content *inside* the control — a search glyph, a unit, a clear or send button. The control's text keeps clear of whatever they hold, and on a textarea they ride the bottom edge, which is where a send button belongs:
258
+
259
+ ```ts
260
+ S.textline({ placeholder: "Search…", prefix: search, suffix: () => S.iconButton({ icon: x, tooltip: "Clear" }) });
261
+ S.textarea({ placeholder: "Message…", bind: A.ref($chat, "text"),
262
+ suffix: () => S.iconButton({ icon: send, tooltip: "Send", click: post }) });
263
+ ```
264
+
265
+ A plain glyph there lets the click through to the field it decorates; a button or a link takes it.
256
266
 
257
267
  `src/index.ts` is the authoritative list of exports.
258
268
 
@@ -408,6 +418,15 @@ A combobox with type-ahead filtering. Supports single or multi-select (chips),
408
418
  optional free-text entry, and full keyboard control (arrows, enter, escape,
409
419
  backspace-to-remove). Implements the ARIA combobox/listbox pattern.
410
420
 
421
+ ## [matchWords](matchWords.md) · function
422
+
423
+ The default type-ahead test: every whitespace-separated term of the query has
424
+ to match the label from a word start on, and no two terms may claim the same
425
+ word. Order doesn't matter, so "se pal" finds "Palette search" just as
426
+ "pal se" does — but "ette" finds nothing, as you type the beginnings of
427
+ words. A term may run past its word's end ("typescript" finds "TypeScript",
428
+ "c++" finds "C++"), so typing a label out in full always finds it.
429
+
411
430
  ## [AutocompleteOptions](AutocompleteOptions.md) · interface
412
431
 
413
432
  Options for `autocomplete`.
@@ -803,6 +822,13 @@ autocomplete, ...). Every field lays out the same way — optional label,
803
822
  control, optional help/error below — which is what lets `form` align
804
823
  groups of them.
805
824
 
825
+ ## [InsetOptions](InsetOptions.md) · interface
826
+
827
+ Options for a field that can carry content *inside* its control box — an
828
+ icon against the leading edge, a button against the trailing one. Added by
829
+ ("./textline").textline and
830
+ ("./textarea").textarea.
831
+
806
832
  ## [ContentOptions](ContentOptions.md) · interface
807
833
 
808
834
  Options for components that wrap a single block of caller-provided content,
@@ -28,7 +28,10 @@ Visible number of text rows. Defaults to `4`. Ignored when `autoGrow` is enabled
28
28
 
29
29
  ### textareaOptions.resize · member
30
30
 
31
- Whether the textarea may be resized by the user. Defaults to `"vertical"`. Ignored when `autoGrow` is enabled.
31
+ Whether the textarea may be resized by the user. Defaults to `"vertical"`,
32
+ or to `"none"` when a | suffix is given — the
33
+ resize grip and the inset both want the bottom-right corner. Ignored when
34
+ `autoGrow` is enabled.
32
35
 
33
36
  **Type:** `"none" | "vertical" | "horizontal" | "both"`
34
37
 
@@ -4,6 +4,10 @@ A combobox with type-ahead filtering. Supports single or multi-select (chips),
4
4
  optional free-text entry, and full keyboard control (arrows, enter, escape,
5
5
  backspace-to-remove). Implements the ARIA combobox/listbox pattern.
6
6
 
7
+ What you type is matched against the start of the label's words, a term at a
8
+ time and in any order — "pal se" finds "Palette search" — see
9
+ `matchWords`, or `AutocompleteOptions.match` to filter your own way.
10
+
7
11
  The suggestion list is portalled to `document.body`, so a dialog or a
8
12
  scrolling column can neither clip it nor grow a scrollbar around it. It hangs
9
13
  off whichever side of the field has the room, and follows it as things move.
package/skill/dialog.md CHANGED
@@ -5,8 +5,9 @@ that fades in and out. Returns a `Promise<void>` that resolves when the dialog
5
5
  closes. Lifecycle is also tied to the parent reactive scope — when that scope
6
6
  is cleaned up the dialog disappears and the promise resolves.
7
7
 
8
- Multiple dialogs stack correctly: each new pair (backdrop + dialog) has a
9
- higher z-index, while older dialogs are pushed behind their covering backdrop.
8
+ Multiple dialogs stack: each new pair (backdrop + dialog) gets a higher
9
+ z-index than the one it covers, while older dialogs are pushed behind their
10
+ covering backdrop.
10
11
 
11
12
  **Signature:** `(opts: DialogOptions) => Promise<void>`
12
13
 
@@ -20,7 +20,9 @@ an icon alone is unambiguous only for a handful of universal actions.
20
20
  import { trash2, share2 } from "staffa/icons";
21
21
 
22
22
  $panel.actions = () => {
23
- S.iconButton({ icon: share2, ariaLabel: "Share", click: share });
23
+ // A string `tooltip` names the button for screen readers too, so one option does.
24
+ S.iconButton({ icon: share2, tooltip: "Share", click: share });
25
+ // Named, but silent on hover: `ariaLabel` alone raises no tooltip.
24
26
  S.iconButton({ icon: trash2, ariaLabel: "Delete", click: del, attrs: "fg:$s-danger" });
25
27
  };
26
28
  ```
@@ -0,0 +1,17 @@
1
+ ## matchWords · function
2
+
3
+ The default type-ahead test: every whitespace-separated term of the query has
4
+ to match the label from a word start on, and no two terms may claim the same
5
+ word. Order doesn't matter, so "se pal" finds "Palette search" just as
6
+ "pal se" does — but "ette" finds nothing, as you type the beginnings of
7
+ words. A term may run past its word's end ("typescript" finds "TypeScript",
8
+ "c++" finds "C++"), so typing a label out in full always finds it.
9
+
10
+ Pass it to `AutocompleteOptions.match` to compose with it.
11
+
12
+ **Signature:** `(label: string, query: string) => boolean`
13
+
14
+ **Parameters:**
15
+
16
+ - `label: string`
17
+ - `query: string`
package/skill/textarea.md CHANGED
@@ -14,4 +14,12 @@ A multi-line text input. Shares the field chrome and styling of
14
14
  ```ts
15
15
  const $user = A.proxy({bio: ""});
16
16
  S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
17
+
18
+ // A chat box: the send button sits in the bottom-right corner, inside the field.
19
+ const $chat = A.proxy({text: ""});
20
+ S.textarea({
21
+ placeholder: "Message…",
22
+ bind: A.ref($chat, "text"),
23
+ suffix: () => S.iconButton({ icon: send, tooltip: "Send", click: () => post($chat.text) }),
24
+ });
17
25
  ```
package/skill/textline.md CHANGED
@@ -15,4 +15,7 @@ chrome (label, control, help/error), so it aligns cleanly inside a `form`.
15
15
  ```ts
16
16
  const $user = A.proxy({email: "test@example.com"});
17
17
  S.textline({ label: "Email", type: "email", required: true, bind: A.ref($user, "email") });
18
+
19
+ // Content inside the field: a glyph against its left edge, a button against its right.
20
+ S.textline({ placeholder: "Search…", prefix: search, suffix: () => S.iconButton({ icon: x, tooltip: "Clear" }) });
18
21
  ```
@@ -26,8 +26,22 @@ export interface AutocompleteOptions extends FieldOptions {
26
26
  bind?: Bindable<string | string[]>;
27
27
  /** Allow selecting several values, shown as removable chips. */
28
28
  multi?: boolean;
29
- /** Allow committing free text that isn't in the options list. Defaults to `true`. */
29
+ /**
30
+ * Allow committing free text that isn't in the options list. Defaults to
31
+ * `true`, and includes no text at all: emptying a single-select field clears
32
+ * its selection (`required` is what makes that an error). With
33
+ * `allowCustom: false` only the options can be committed, so anything else
34
+ * springs back to the current selection when the field loses focus.
35
+ */
30
36
  allowCustom?: boolean;
37
+ /**
38
+ * The type-ahead test, run for each option against what has been typed.
39
+ * Defaults to {@link matchWords}. Pass your own to filter differently — say
40
+ * `(label, q) => label.toLowerCase().includes(q.toLowerCase())` for plain
41
+ * substring matching, or something that also looks at an option's other
42
+ * fields. Matching never reorders: options are shown as given.
43
+ */
44
+ match?: (label: string, query: string) => boolean;
31
45
  /** Placeholder for the text input. */
32
46
  placeholder?: string;
33
47
  }
@@ -110,6 +124,41 @@ mountPortal(() => {
110
124
  sizeChanged = followAnchor(p.anchor, (r) => place(el, r));
111
125
  });
112
126
 
127
+ /**
128
+ * Where each word of a label begins: runs of letters or digits, split again at
129
+ * camelCase humps — "TypeScript" starts a word at `T` and at `S`.
130
+ */
131
+ function wordStarts(label: string): number[] {
132
+ const starts: number[] = [];
133
+ for (const m of label.matchAll(/\p{N}+|\p{Lu}+(?=\p{Lu}\p{Ll})|\p{Lu}?\p{Ll}+|\p{Lu}+/gu)) starts.push(m.index);
134
+ return starts;
135
+ }
136
+
137
+ /**
138
+ * The default type-ahead test: every whitespace-separated term of the query has
139
+ * to match the label from a word start on, and no two terms may claim the same
140
+ * word. Order doesn't matter, so "se pal" finds "Palette search" just as
141
+ * "pal se" does — but "ette" finds nothing, as you type the beginnings of
142
+ * words. A term may run past its word's end ("typescript" finds "TypeScript",
143
+ * "c++" finds "C++"), so typing a label out in full always finds it.
144
+ *
145
+ * Pass it to {@link AutocompleteOptions.match} to compose with it.
146
+ */
147
+ export function matchWords(label: string, query: string): boolean {
148
+ // Longest term first: it is the most constrained, so the greedy claim below
149
+ // doesn't let a short term take the word a long one needed.
150
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean).sort((a, b) => b.length - a.length);
151
+ if (!terms.length) return true;
152
+ const starts = wordStarts(label);
153
+ const used: boolean[] = [];
154
+ return terms.every((t) => {
155
+ const i = starts.findIndex((s, i) => !used[i] && label.slice(s, s + t.length).toLowerCase() === t);
156
+ if (i < 0) return false;
157
+ used[i] = true;
158
+ return true;
159
+ });
160
+ }
161
+
113
162
  function normOption(o: AutocompleteOptionInput): AcOption {
114
163
  return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
115
164
  }
@@ -119,6 +168,10 @@ function normOption(o: AutocompleteOptionInput): AcOption {
119
168
  * optional free-text entry, and full keyboard control (arrows, enter, escape,
120
169
  * backspace-to-remove). Implements the ARIA combobox/listbox pattern.
121
170
  *
171
+ * What you type is matched against the start of the label's words, a term at a
172
+ * time and in any order — "pal se" finds "Palette search" — see
173
+ * {@link matchWords}, or {@link AutocompleteOptions.match} to filter your own way.
174
+ *
122
175
  * The suggestion list is portalled to `document.body`, so a dialog or a
123
176
  * scrolling column can neither clip it nor grow a scrollbar around it. It hangs
124
177
  * off whichever side of the field has the room, and follows it as things move.
@@ -154,6 +207,10 @@ export function autocomplete(opts: AutocompleteOptions): void {
154
207
  return Array.isArray(v) ? v : [v];
155
208
  };
156
209
  const labelFor = (value: string): string => getOptions().find((o) => o.value === value)?.label ?? value;
210
+ // What committing free text means: the option the text names, or the text itself.
211
+ // Typing a label out in full is picking that option, not inventing a value.
212
+ const valueForText = (text: string): string =>
213
+ text ? getOptions().find((o) => o.label.toLowerCase() === text.toLowerCase())?.value ?? text : "";
157
214
 
158
215
  // Seed the input with the current single-selection's label.
159
216
  if (!opts.multi) {
@@ -165,8 +222,9 @@ export function autocomplete(opts: AutocompleteOptions): void {
165
222
  const sel = new Set(selectedValues());
166
223
  let list = getOptions();
167
224
  if (opts.multi) list = list.filter((o) => !sel.has(o.value));
168
- const q = $st.query.trim().toLowerCase();
169
- if (q) list = list.filter((o) => o.label.toLowerCase().includes(q));
225
+ // Cased as typed: the matcher splits on camelCase humps, and lowercases itself.
226
+ const q = $st.query.trim();
227
+ if (q) list = list.filter((o) => (opts.match ?? matchWords)(o.label, q));
170
228
  return list;
171
229
  };
172
230
 
@@ -316,21 +374,27 @@ export function autocomplete(opts: AutocompleteOptions): void {
316
374
  } else if (e.key === "Enter") {
317
375
  // Always prevent default to avoid accidental form submission.
318
376
  e.preventDefault();
319
- const chosen = list[$st.active];
377
+ const q = $st.query.trim();
378
+ // Only a row of a list that is up: with it hidden there is no highlight to
379
+ // be seen, so Enter takes what has been typed instead.
380
+ const chosen = $st.open ? list[$st.active] : undefined;
320
381
  if (chosen) {
321
382
  commit(chosen.value, inputEl);
322
- } else if (opts.allowCustom !== false && $st.query.trim()) {
323
- commit($st.query.trim(), inputEl);
383
+ } else if (opts.allowCustom !== false && (q || !opts.multi)) {
384
+ // No text is a commit too, in single mode: it clears the selection.
385
+ // (In multi mode there is nothing to clear, and no chip to make.)
386
+ commit(valueForText(q), inputEl);
324
387
  } else if ($st.open) {
388
+ // Nothing to commit (the options are all there is, and none match).
325
389
  $st.open = false;
326
390
  }
327
391
  } else if (e.key === "Escape") {
328
- // Only consume Escape while the list is showing: it dismisses the innermost
329
- // layer, so a surrounding dialog closes on the next press, not this one.
392
+ // Escape hides the list and leaves what was typed standing — it dismisses
393
+ // a layer, it doesn't undo an edit. Only consumed while the list is up, so
394
+ // a surrounding dialog closes on the next press, not this one.
330
395
  if ($st.open) {
331
396
  e.preventDefault();
332
397
  $st.open = false;
333
- if (!opts.multi) $st.query = labelFor(selectedValues()[0] ?? "");
334
398
  }
335
399
  } else if (e.key === "Backspace" && opts.multi && $st.query === "") {
336
400
  const sel = selectedValues();
@@ -342,10 +406,12 @@ export function autocomplete(opts: AutocompleteOptions): void {
342
406
  $st.open = false;
343
407
  if (opts.multi) {
344
408
  $st.query = "";
345
- } else if (opts.allowCustom !== false && $st.query.trim()) {
346
- commit($st.query.trim());
409
+ } else if (opts.allowCustom !== false) {
410
+ // Free text stands as typed — nothing at all included, which is how a
411
+ // single selection is cleared. (`required` is what makes empty an error.)
412
+ commit(valueForText($st.query.trim()));
347
413
  } else {
348
- // Revert to the committed selection's label.
414
+ // Only the options exist, so anything else reverts to the selected one.
349
415
  $st.query = labelFor(selectedValues()[0] ?? "");
350
416
  }
351
417
  }
@@ -7,8 +7,12 @@ import { addTooltip } from "./tooltip.js";
7
7
  export interface IconButtonOptions {
8
8
  /** The glyph, usually one of the `staffa/icons` draw functions. */
9
9
  icon: Slot;
10
- /** What it does, for screen readers. Required: there is no visible text to read. */
11
- ariaLabel: string;
10
+ /**
11
+ * What it does, for screen readers: there is no visible text to read. Required,
12
+ * unless the `tooltip` is a string — that names the button just as well, and is
13
+ * taken as the label when this is left out.
14
+ */
15
+ ariaLabel?: string;
12
16
  /**
13
17
  * Click handler. Return a promise and the glyph becomes a spinner until it
14
18
  * settles, with further clicks bouncing off — see {@link ButtonOptions.click}.
@@ -16,16 +20,17 @@ export interface IconButtonOptions {
16
20
  click?: (event: Event) => unknown;
17
21
  /**
18
22
  * A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
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.
23
+ * It is shown after the button's `tooltip`, or alone in a tooltip of its own
24
+ * when there is none, and the `?` overview lists it under the button's label.
22
25
  */
23
26
  key?: string;
24
27
  /**
25
28
  * 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
+ * text. There is none unless you ask for one — but do consider it here, as
30
+ * a glyph says nothing to whoever cannot guess it. A string doubles as the
31
+ * `ariaLabel` when that is left out, so an icon button usually needs one
32
+ * option, not two. A `key` is appended to the tip, behind a `·`; pass
33
+ * `false` to suppress even that.
29
34
  *
30
35
  * Works on a disabled button too, which is where a tooltip earns its keep:
31
36
  * it is the only room there is to say why.
@@ -67,7 +72,10 @@ export interface ButtonOptions {
67
72
  type?: "button" | "submit" | "reset";
68
73
  /** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
69
74
  href?: string;
70
- /** Accessible label, when the button has only an icon. */
75
+ /**
76
+ * Accessible label, when the button has only an icon. A string `tooltip` is
77
+ * taken as the label of such a button when this is left out.
78
+ */
71
79
  ariaLabel?: string;
72
80
  /**
73
81
  * A keyboard shortcut that presses this button: `"mod+s"`, `"f2"` — see
@@ -81,9 +89,10 @@ export interface ButtonOptions {
81
89
  key?: string;
82
90
  /**
83
91
  * 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.
92
+ * text, a function draws its own markup. There is none unless you ask for
93
+ * one. A `key` is appended to it, behind a `·`; pass `false` to suppress
94
+ * even that. On an icon-only button, a string tooltip doubles as the
95
+ * `ariaLabel` when that is left out.
87
96
  *
88
97
  * Works on a disabled button too, which is where a tooltip earns its keep:
89
98
  * it is the only room there is to say why.
@@ -184,22 +193,25 @@ A.insertGlobalCss({
184
193
  * import { trash2, share2 } from "staffa/icons";
185
194
  *
186
195
  * $panel.actions = () => {
187
- * S.iconButton({ icon: share2, ariaLabel: "Share", click: share });
196
+ * // A string `tooltip` names the button for screen readers too, so one option does.
197
+ * S.iconButton({ icon: share2, tooltip: "Share", click: share });
198
+ * // Named, but silent on hover: `ariaLabel` alone raises no tooltip.
188
199
  * S.iconButton({ icon: trash2, ariaLabel: "Delete", click: del, attrs: "fg:$s-danger" });
189
200
  * };
190
201
  * ```
191
202
  */
192
203
  export function iconButton(opts: IconButtonOptions): void {
193
204
  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;
205
+ const tip = opts.tooltip === false ? undefined : opts.tooltip;
206
+ // A glyph says nothing on its own, so the button needs a name — and a tooltip
207
+ // written as a string already is one. Said once, it serves both.
208
+ const label = opts.ariaLabel ?? (typeof tip === "string" ? plainText(tip) : undefined);
197
209
  A(`${tag}.s-icon-btn`, opts.attrs, () => {
198
210
  applyActionBehavior(opts, tip != null);
199
- A("aria-label=", opts.ariaLabel);
211
+ A("aria-label=", label);
200
212
  // Before the glyph, so a tooltip the caller adds in there is the later of
201
213
  // the two and wins the hover.
202
- applyTooltipAndKey(tip, opts.key, opts.ariaLabel, opts.disabled);
214
+ applyTooltipAndKey(opts.tooltip, opts.key, label, opts.disabled);
203
215
  drawSlot(opts.icon);
204
216
  });
205
217
  }
@@ -265,24 +277,41 @@ function applyClick(click: (event: Event) => unknown): void {
265
277
  });
266
278
  }
267
279
 
280
+ /**
281
+ * A rich-text string as a screen reader should hear it. Same pattern Aberdeen's
282
+ * `rich=` draws with, so a tooltip standing in as the accessible name says the
283
+ * words it shows, and not its own asterisks and brackets.
284
+ */
285
+ function plainText(rich: string): string {
286
+ return rich.replace(/\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|\[(.+?)\]\((.+?)\)/g,
287
+ (_m, bold, italic, code, link) => bold ?? italic ?? code ?? link);
288
+ }
289
+
268
290
  /**
269
291
  * The tooltip and shortcut plumbing {@link button} and {@link iconButton} share:
270
292
  * show the tip, with the key appended — the only place a button can say what its
271
293
  * key is without shouting it beside the label — then bind that key and announce
272
- * it as `aria-keyshortcuts`.
294
+ * it as `aria-keyshortcuts`. A key with no tooltip to join gets a tip of its
295
+ * own, saying the combination and no more.
273
296
  *
274
- * Pressing it clicks the element rather than calling `click` directly, so a
297
+ * Pressing the key clicks the element rather than calling `click` directly, so a
275
298
  * `type=submit` still submits its form and an `href` still navigates. Call this
276
299
  * inside the button's own element scope, whose element it takes and whose life
277
- * the binding follows.
300
+ * the binding follows. `keyLabel` is how the `?` overview names the shortcut.
278
301
  */
279
- function applyTooltipAndKey(tip: Slot | undefined, key: string | undefined, label: string | undefined, disabled?: boolean): void {
280
- if (tip != null || key) {
302
+ function applyTooltipAndKey(
303
+ tooltip: Slot | false | undefined,
304
+ key: string | undefined,
305
+ keyLabel: string | undefined,
306
+ disabled?: boolean,
307
+ ): void {
308
+ // `false` is a vow of silence: not even a key raises a tip on this one.
309
+ if (tooltip !== false && (tooltip != null || key)) {
281
310
  addTooltip({
282
311
  tip: () => {
283
- drawSlot(tip);
312
+ drawSlot(tooltip);
284
313
  // A draw function, not a string: a key like `*` is markup to rich text.
285
- if (key) A("#", tip == null ? formatKey(key) : ` · ${formatKey(key)}`);
314
+ if (key) A("#", tooltip == null ? formatKey(key) : ` · ${formatKey(key)}`);
286
315
  },
287
316
  });
288
317
  }
@@ -292,7 +321,7 @@ function applyTooltipAndKey(tip: Slot | undefined, key: string | undefined, labe
292
321
  if (key && !disabled) {
293
322
  const el = A() as HTMLElement;
294
323
  A("aria-keyshortcuts=", formatKey(key, true));
295
- bindKey(key, label, () => el.click());
324
+ bindKey(key, keyLabel, () => el.click());
296
325
  }
297
326
  }
298
327
 
@@ -326,18 +355,19 @@ export function button(opts: ButtonOptions | Slot = {}): void {
326
355
  const o: ButtonOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
327
356
 
328
357
  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;
358
+ const tip = o.tooltip === false ? undefined : o.tooltip;
359
+ // Only a button without visible text needs naming, and a string tooltip is a
360
+ // name: on one that has text, an aria-label would *hide* that text from AT.
361
+ const label = o.ariaLabel ?? (o.content == null && typeof tip === "string" ? plainText(tip) : undefined);
332
362
 
333
363
  // A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
334
364
  // detection here: `attrs` just names another role or variant.
335
365
  A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
336
366
  applyActionBehavior(o, tip != null);
337
- if (o.ariaLabel) A("aria-label=", o.ariaLabel);
367
+ if (label) A("aria-label=", label);
338
368
  // Before the content, so a tooltip the caller adds in there is the later of
339
369
  // the two and wins the hover.
340
- applyTooltipAndKey(tip, o.key, typeof o.content === "string" ? o.content : o.ariaLabel, o.disabled);
370
+ applyTooltipAndKey(o.tooltip, o.key, typeof o.content === "string" ? o.content : label, o.disabled);
341
371
 
342
372
  drawSlot(o.icon);
343
373
  drawSlot(o.content);
@@ -51,6 +51,9 @@ export interface DialogOptions {
51
51
  onClose?: () => void;
52
52
  }
53
53
 
54
+ /** The layer the bottom dialog's backdrop is painted on; each dialog adds two. */
55
+ const BASE_Z = 200;
56
+
54
57
  A.insertGlobalCss({
55
58
  ".s-backdrop": {
56
59
  "&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
@@ -80,6 +83,8 @@ A.insertGlobalCss({
80
83
  const dialogs = A.proxy({} as Record<string,{resolve: (value: void | PromiseLike<void>) => void, opts: DialogOptions}>);
81
84
  let dialogCount = 0;
82
85
 
86
+ // `Object.keys` orders integer-like keys numerically, so the last one is the
87
+ // newest dialog — the one on top.
83
88
  const topDialogId = A.derive(() => {
84
89
  const keys = Object.keys(dialogs);
85
90
  if (keys.length) return keys[keys.length-1];
@@ -91,6 +96,9 @@ export function isDialogOpen(): boolean {
91
96
  }
92
97
 
93
98
  mountPortal(() => {
99
+ // Sorted numerically: the keys are numbers, and left to `onEach`'s default
100
+ // string ordering "10" would sort before "2", putting the tenth dialog
101
+ // before (hence behind) a still-open second one.
94
102
  A.onEach(dialogs, ({resolve, opts}, dialogId) => {
95
103
  const close = () => { delete dialogs[dialogId]; };
96
104
 
@@ -109,9 +117,9 @@ mountPortal(() => {
109
117
 
110
118
  // Backdrop - hide when not the top dialog
111
119
  const overlaid = A.derive(() => topDialogId.value != dialogId);
112
- A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
120
+ const backdropEl = A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
113
121
  if (opts.allowCancel !== false) close();
114
- });
122
+ }) as HTMLElement;
115
123
 
116
124
  // Derived from the dialog's own key rather than a second counter: there is
117
125
  // exactly one of these per dialog, for as long as the dialog exists.
@@ -164,10 +172,28 @@ mountPortal(() => {
164
172
  });
165
173
  }) as HTMLElement;
166
174
 
175
+ // Stacking is stated, not left to the order the elements happen to sit in
176
+ // the DOM: a dialog sits a layer above its own backdrop, and that pair a
177
+ // layer above the dialog it covers. (A closing dialog has left the stack
178
+ // already; it keeps the layer it had while it fades out.)
179
+ let depth = 0;
180
+ A(() => {
181
+ const index = Object.keys(dialogs).indexOf(dialogId);
182
+ if (index >= 0) depth = index;
183
+ backdropEl.style.zIndex = `${BASE_Z + 2*depth}`;
184
+ dialogEl.style.zIndex = `${BASE_Z + 2*depth + 1}`;
185
+ });
186
+
167
187
  // Once laid out, move focus into the dialog so it's keyboard-ready and focus
168
188
  // doesn't linger on whatever opened it.
169
- requestAnimationFrame(() => { if (document.body.contains(dialogEl)) focusFirst(dialogEl); });
170
- });
189
+ requestAnimationFrame(() => {
190
+ // Only the top dialog claims focus: one opened *under* another (the
191
+ // covering dialog opened straight after it) must not pull focus into
192
+ // fields the user can't see.
193
+ if (A.peek(() => topDialogId.value) !== dialogId) return;
194
+ if (document.body.contains(dialogEl)) focusFirst(dialogEl);
195
+ });
196
+ }, (_value, dialogId) => +dialogId);
171
197
  })
172
198
 
173
199
  /**
@@ -192,8 +218,9 @@ function trapTab(dialogEl: HTMLElement, event: KeyboardEvent): void {
192
218
  * closes. Lifecycle is also tied to the parent reactive scope — when that scope
193
219
  * is cleaned up the dialog disappears and the promise resolves.
194
220
  *
195
- * Multiple dialogs stack correctly: each new pair (backdrop + dialog) has a
196
- * higher z-index, while older dialogs are pushed behind their covering backdrop.
221
+ * Multiple dialogs stack: each new pair (backdrop + dialog) gets a higher
222
+ * z-index than the one it covers, while older dialogs are pushed behind their
223
+ * covering backdrop.
197
224
  *
198
225
  * @example
199
226
  * ```ts