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/README.md CHANGED
@@ -244,10 +244,20 @@ Buttons, icon buttons and menu items carry a `tooltip` option of their own, so m
244
244
 
245
245
  ```ts
246
246
  S.button({ content: "Publish", tooltip: "Not until the draft validates", disabled: true });
247
- S.iconButton({ icon: trash2, ariaLabel: "Delete" }); // says "Delete" on hover
247
+ S.iconButton({ icon: trash2, tooltip: "Delete" }); // says "Delete" on hover, and to screen readers
248
248
  ```
249
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.
250
+ 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.
251
+
252
+ `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:
253
+
254
+ ```ts
255
+ S.textline({ placeholder: "Search…", prefix: search, suffix: () => S.iconButton({ icon: x, tooltip: "Clear" }) });
256
+ S.textarea({ placeholder: "Message…", bind: A.ref($chat, "text"),
257
+ suffix: () => S.iconButton({ icon: send, tooltip: "Send", click: post }) });
258
+ ```
259
+
260
+ A plain glyph there lets the click through to the field it decorates; a button or a link takes it.
251
261
 
252
262
  `src/index.ts` is the authoritative list of exports.
253
263
 
@@ -21,16 +21,45 @@ export interface AutocompleteOptions extends FieldOptions {
21
21
  bind?: Bindable<string | string[]>;
22
22
  /** Allow selecting several values, shown as removable chips. */
23
23
  multi?: boolean;
24
- /** Allow committing free text that isn't in the options list. Defaults to `true`. */
24
+ /**
25
+ * Allow committing free text that isn't in the options list. Defaults to
26
+ * `true`, and includes no text at all: emptying a single-select field clears
27
+ * its selection (`required` is what makes that an error). With
28
+ * `allowCustom: false` only the options can be committed, so anything else
29
+ * springs back to the current selection when the field loses focus.
30
+ */
25
31
  allowCustom?: boolean;
32
+ /**
33
+ * The type-ahead test, run for each option against what has been typed.
34
+ * Defaults to {@link matchWords}. Pass your own to filter differently — say
35
+ * `(label, q) => label.toLowerCase().includes(q.toLowerCase())` for plain
36
+ * substring matching, or something that also looks at an option's other
37
+ * fields. Matching never reorders: options are shown as given.
38
+ */
39
+ match?: (label: string, query: string) => boolean;
26
40
  /** Placeholder for the text input. */
27
41
  placeholder?: string;
28
42
  }
43
+ /**
44
+ * The default type-ahead test: every whitespace-separated term of the query has
45
+ * to match the label from a word start on, and no two terms may claim the same
46
+ * word. Order doesn't matter, so "se pal" finds "Palette search" just as
47
+ * "pal se" does — but "ette" finds nothing, as you type the beginnings of
48
+ * words. A term may run past its word's end ("typescript" finds "TypeScript",
49
+ * "c++" finds "C++"), so typing a label out in full always finds it.
50
+ *
51
+ * Pass it to {@link AutocompleteOptions.match} to compose with it.
52
+ */
53
+ export declare function matchWords(label: string, query: string): boolean;
29
54
  /**
30
55
  * A combobox with type-ahead filtering. Supports single or multi-select (chips),
31
56
  * optional free-text entry, and full keyboard control (arrows, enter, escape,
32
57
  * backspace-to-remove). Implements the ARIA combobox/listbox pattern.
33
58
  *
59
+ * What you type is matched against the start of the label's words, a term at a
60
+ * time and in any order — "pal se" finds "Palette search" — see
61
+ * {@link matchWords}, or {@link AutocompleteOptions.match} to filter your own way.
62
+ *
34
63
  * The suggestion list is portalled to `document.body`, so a dialog or a
35
64
  * scrolling column can neither clip it nor grow a scrollbar around it. It hangs
36
65
  * off whichever side of the field has the room, and follows it as things move.
@@ -62,6 +62,42 @@ mountPortal(() => {
62
62
  });
63
63
  sizeChanged = followAnchor(p.anchor, (r) => place(el, r));
64
64
  });
65
+ /**
66
+ * Where each word of a label begins: runs of letters or digits, split again at
67
+ * camelCase humps — "TypeScript" starts a word at `T` and at `S`.
68
+ */
69
+ function wordStarts(label) {
70
+ const starts = [];
71
+ for (const m of label.matchAll(/\p{N}+|\p{Lu}+(?=\p{Lu}\p{Ll})|\p{Lu}?\p{Ll}+|\p{Lu}+/gu))
72
+ starts.push(m.index);
73
+ return starts;
74
+ }
75
+ /**
76
+ * The default type-ahead test: every whitespace-separated term of the query has
77
+ * to match the label from a word start on, and no two terms may claim the same
78
+ * word. Order doesn't matter, so "se pal" finds "Palette search" just as
79
+ * "pal se" does — but "ette" finds nothing, as you type the beginnings of
80
+ * words. A term may run past its word's end ("typescript" finds "TypeScript",
81
+ * "c++" finds "C++"), so typing a label out in full always finds it.
82
+ *
83
+ * Pass it to {@link AutocompleteOptions.match} to compose with it.
84
+ */
85
+ export function matchWords(label, query) {
86
+ // Longest term first: it is the most constrained, so the greedy claim below
87
+ // doesn't let a short term take the word a long one needed.
88
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean).sort((a, b) => b.length - a.length);
89
+ if (!terms.length)
90
+ return true;
91
+ const starts = wordStarts(label);
92
+ const used = [];
93
+ return terms.every((t) => {
94
+ const i = starts.findIndex((s, i) => !used[i] && label.slice(s, s + t.length).toLowerCase() === t);
95
+ if (i < 0)
96
+ return false;
97
+ used[i] = true;
98
+ return true;
99
+ });
100
+ }
65
101
  function normOption(o) {
66
102
  return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
67
103
  }
@@ -70,6 +106,10 @@ function normOption(o) {
70
106
  * optional free-text entry, and full keyboard control (arrows, enter, escape,
71
107
  * backspace-to-remove). Implements the ARIA combobox/listbox pattern.
72
108
  *
109
+ * What you type is matched against the start of the label's words, a term at a
110
+ * time and in any order — "pal se" finds "Palette search" — see
111
+ * {@link matchWords}, or {@link AutocompleteOptions.match} to filter your own way.
112
+ *
73
113
  * The suggestion list is portalled to `document.body`, so a dialog or a
74
114
  * scrolling column can neither clip it nor grow a scrollbar around it. It hangs
75
115
  * off whichever side of the field has the room, and follows it as things move.
@@ -105,6 +145,9 @@ export function autocomplete(opts) {
105
145
  return Array.isArray(v) ? v : [v];
106
146
  };
107
147
  const labelFor = (value) => getOptions().find((o) => o.value === value)?.label ?? value;
148
+ // What committing free text means: the option the text names, or the text itself.
149
+ // Typing a label out in full is picking that option, not inventing a value.
150
+ const valueForText = (text) => text ? getOptions().find((o) => o.label.toLowerCase() === text.toLowerCase())?.value ?? text : "";
108
151
  // Seed the input with the current single-selection's label.
109
152
  if (!opts.multi) {
110
153
  const v = opts.bind ? A.peek(opts.bind, 'value') : undefined;
@@ -116,9 +159,10 @@ export function autocomplete(opts) {
116
159
  let list = getOptions();
117
160
  if (opts.multi)
118
161
  list = list.filter((o) => !sel.has(o.value));
119
- const q = $st.query.trim().toLowerCase();
162
+ // Cased as typed: the matcher splits on camelCase humps, and lowercases itself.
163
+ const q = $st.query.trim();
120
164
  if (q)
121
- list = list.filter((o) => o.label.toLowerCase().includes(q));
165
+ list = list.filter((o) => (opts.match ?? matchWords)(o.label, q));
122
166
  return list;
123
167
  };
124
168
  const commit = (value, inputEl) => {
@@ -271,25 +315,30 @@ export function autocomplete(opts) {
271
315
  else if (e.key === "Enter") {
272
316
  // Always prevent default to avoid accidental form submission.
273
317
  e.preventDefault();
274
- const chosen = list[$st.active];
318
+ const q = $st.query.trim();
319
+ // Only a row of a list that is up: with it hidden there is no highlight to
320
+ // be seen, so Enter takes what has been typed instead.
321
+ const chosen = $st.open ? list[$st.active] : undefined;
275
322
  if (chosen) {
276
323
  commit(chosen.value, inputEl);
277
324
  }
278
- else if (opts.allowCustom !== false && $st.query.trim()) {
279
- commit($st.query.trim(), inputEl);
325
+ else if (opts.allowCustom !== false && (q || !opts.multi)) {
326
+ // No text is a commit too, in single mode: it clears the selection.
327
+ // (In multi mode there is nothing to clear, and no chip to make.)
328
+ commit(valueForText(q), inputEl);
280
329
  }
281
330
  else if ($st.open) {
331
+ // Nothing to commit (the options are all there is, and none match).
282
332
  $st.open = false;
283
333
  }
284
334
  }
285
335
  else if (e.key === "Escape") {
286
- // Only consume Escape while the list is showing: it dismisses the innermost
287
- // layer, so a surrounding dialog closes on the next press, not this one.
336
+ // Escape hides the list and leaves what was typed standing — it dismisses
337
+ // a layer, it doesn't undo an edit. Only consumed while the list is up, so
338
+ // a surrounding dialog closes on the next press, not this one.
288
339
  if ($st.open) {
289
340
  e.preventDefault();
290
341
  $st.open = false;
291
- if (!opts.multi)
292
- $st.query = labelFor(selectedValues()[0] ?? "");
293
342
  }
294
343
  }
295
344
  else if (e.key === "Backspace" && opts.multi && $st.query === "") {
@@ -303,11 +352,13 @@ export function autocomplete(opts) {
303
352
  if (opts.multi) {
304
353
  $st.query = "";
305
354
  }
306
- else if (opts.allowCustom !== false && $st.query.trim()) {
307
- commit($st.query.trim());
355
+ else if (opts.allowCustom !== false) {
356
+ // Free text stands as typed — nothing at all included, which is how a
357
+ // single selection is cleared. (`required` is what makes empty an error.)
358
+ commit(valueForText($st.query.trim()));
308
359
  }
309
360
  else {
310
- // Revert to the committed selection's label.
361
+ // Only the options exist, so anything else reverts to the selected one.
311
362
  $st.query = labelFor(selectedValues()[0] ?? "");
312
363
  }
313
364
  }
@@ -3,8 +3,12 @@ import { type Slot, type Attributes } from "../core.js";
3
3
  export interface IconButtonOptions {
4
4
  /** The glyph, usually one of the `staffa/icons` draw functions. */
5
5
  icon: Slot;
6
- /** What it does, for screen readers. Required: there is no visible text to read. */
7
- ariaLabel: string;
6
+ /**
7
+ * What it does, for screen readers: there is no visible text to read. Required,
8
+ * unless the `tooltip` is a string — that names the button just as well, and is
9
+ * taken as the label when this is left out.
10
+ */
11
+ ariaLabel?: string;
8
12
  /**
9
13
  * Click handler. Return a promise and the glyph becomes a spinner until it
10
14
  * settles, with further clicks bouncing off — see {@link ButtonOptions.click}.
@@ -12,16 +16,17 @@ export interface IconButtonOptions {
12
16
  click?: (event: Event) => unknown;
13
17
  /**
14
18
  * A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
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.
19
+ * It is shown after the button's `tooltip`, or alone in a tooltip of its own
20
+ * when there is none, and the `?` overview lists it under the button's label.
18
21
  */
19
22
  key?: string;
20
23
  /**
21
24
  * 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
+ * text. There is none unless you ask for one but do consider it here, as
26
+ * a glyph says nothing to whoever cannot guess it. A string doubles as the
27
+ * `ariaLabel` when that is left out, so an icon button usually needs one
28
+ * option, not two. A `key` is appended to the tip, behind a `·`; pass
29
+ * `false` to suppress even that.
25
30
  *
26
31
  * Works on a disabled button too, which is where a tooltip earns its keep:
27
32
  * it is the only room there is to say why.
@@ -62,7 +67,10 @@ export interface ButtonOptions {
62
67
  type?: "button" | "submit" | "reset";
63
68
  /** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
64
69
  href?: string;
65
- /** Accessible label, when the button has only an icon. */
70
+ /**
71
+ * Accessible label, when the button has only an icon. A string `tooltip` is
72
+ * taken as the label of such a button when this is left out.
73
+ */
66
74
  ariaLabel?: string;
67
75
  /**
68
76
  * A keyboard shortcut that presses this button: `"mod+s"`, `"f2"` — see
@@ -76,9 +84,10 @@ export interface ButtonOptions {
76
84
  key?: string;
77
85
  /**
78
86
  * 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.
87
+ * text, a function draws its own markup. There is none unless you ask for
88
+ * one. A `key` is appended to it, behind a `·`; pass `false` to suppress
89
+ * even that. On an icon-only button, a string tooltip doubles as the
90
+ * `ariaLabel` when that is left out.
82
91
  *
83
92
  * Works on a disabled button too, which is where a tooltip earns its keep:
84
93
  * it is the only room there is to say why.
@@ -110,7 +119,9 @@ export interface ButtonOptions {
110
119
  * import { trash2, share2 } from "staffa/icons";
111
120
  *
112
121
  * $panel.actions = () => {
113
- * S.iconButton({ icon: share2, ariaLabel: "Share", click: share });
122
+ * // A string `tooltip` names the button for screen readers too, so one option does.
123
+ * S.iconButton({ icon: share2, tooltip: "Share", click: share });
124
+ * // Named, but silent on hover: `ariaLabel` alone raises no tooltip.
114
125
  * S.iconButton({ icon: trash2, ariaLabel: "Delete", click: del, attrs: "fg:$s-danger" });
115
126
  * };
116
127
  * ```
@@ -77,22 +77,25 @@ A.insertGlobalCss({
77
77
  * import { trash2, share2 } from "staffa/icons";
78
78
  *
79
79
  * $panel.actions = () => {
80
- * S.iconButton({ icon: share2, ariaLabel: "Share", click: share });
80
+ * // A string `tooltip` names the button for screen readers too, so one option does.
81
+ * S.iconButton({ icon: share2, tooltip: "Share", click: share });
82
+ * // Named, but silent on hover: `ariaLabel` alone raises no tooltip.
81
83
  * S.iconButton({ icon: trash2, ariaLabel: "Delete", click: del, attrs: "fg:$s-danger" });
82
84
  * };
83
85
  * ```
84
86
  */
85
87
  export function iconButton(opts) {
86
88
  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;
89
+ const tip = opts.tooltip === false ? undefined : opts.tooltip;
90
+ // A glyph says nothing on its own, so the button needs a name and a tooltip
91
+ // written as a string already is one. Said once, it serves both.
92
+ const label = opts.ariaLabel ?? (typeof tip === "string" ? plainText(tip) : undefined);
90
93
  A(`${tag}.s-icon-btn`, opts.attrs, () => {
91
94
  applyActionBehavior(opts, tip != null);
92
- A("aria-label=", opts.ariaLabel);
95
+ A("aria-label=", label);
93
96
  // Before the glyph, so a tooltip the caller adds in there is the later of
94
97
  // the two and wins the hover.
95
- applyTooltipAndKey(tip, opts.key, opts.ariaLabel, opts.disabled);
98
+ applyTooltipAndKey(opts.tooltip, opts.key, label, opts.disabled);
96
99
  drawSlot(opts.icon);
97
100
  });
98
101
  }
@@ -159,25 +162,35 @@ function applyClick(click) {
159
162
  Promise.resolve(result).then(done, (err) => { done(); throw err; });
160
163
  });
161
164
  }
165
+ /**
166
+ * A rich-text string as a screen reader should hear it. Same pattern Aberdeen's
167
+ * `rich=` draws with, so a tooltip standing in as the accessible name says the
168
+ * words it shows, and not its own asterisks and brackets.
169
+ */
170
+ function plainText(rich) {
171
+ return rich.replace(/\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|\[(.+?)\]\((.+?)\)/g, (_m, bold, italic, code, link) => bold ?? italic ?? code ?? link);
172
+ }
162
173
  /**
163
174
  * The tooltip and shortcut plumbing {@link button} and {@link iconButton} share:
164
175
  * show the tip, with the key appended — the only place a button can say what its
165
176
  * key is without shouting it beside the label — then bind that key and announce
166
- * it as `aria-keyshortcuts`.
177
+ * it as `aria-keyshortcuts`. A key with no tooltip to join gets a tip of its
178
+ * own, saying the combination and no more.
167
179
  *
168
- * Pressing it clicks the element rather than calling `click` directly, so a
180
+ * Pressing the key clicks the element rather than calling `click` directly, so a
169
181
  * `type=submit` still submits its form and an `href` still navigates. Call this
170
182
  * inside the button's own element scope, whose element it takes and whose life
171
- * the binding follows.
183
+ * the binding follows. `keyLabel` is how the `?` overview names the shortcut.
172
184
  */
173
- function applyTooltipAndKey(tip, key, label, disabled) {
174
- if (tip != null || key) {
185
+ function applyTooltipAndKey(tooltip, key, keyLabel, disabled) {
186
+ // `false` is a vow of silence: not even a key raises a tip on this one.
187
+ if (tooltip !== false && (tooltip != null || key)) {
175
188
  addTooltip({
176
189
  tip: () => {
177
- drawSlot(tip);
190
+ drawSlot(tooltip);
178
191
  // A draw function, not a string: a key like `*` is markup to rich text.
179
192
  if (key)
180
- A("#", tip == null ? formatKey(key) : ` · ${formatKey(key)}`);
193
+ A("#", tooltip == null ? formatKey(key) : ` · ${formatKey(key)}`);
181
194
  },
182
195
  });
183
196
  }
@@ -187,7 +200,7 @@ function applyTooltipAndKey(tip, key, label, disabled) {
187
200
  if (key && !disabled) {
188
201
  const el = A();
189
202
  A("aria-keyshortcuts=", formatKey(key, true));
190
- bindKey(key, label, () => el.click());
203
+ bindKey(key, keyLabel, () => el.click());
191
204
  }
192
205
  }
193
206
  /**
@@ -219,18 +232,19 @@ function applyTooltipAndKey(tip, key, label, disabled) {
219
232
  export function button(opts = {}) {
220
233
  const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
221
234
  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;
235
+ const tip = o.tooltip === false ? undefined : o.tooltip;
236
+ // Only a button without visible text needs naming, and a string tooltip is a
237
+ // name: on one that has text, an aria-label would *hide* that text from AT.
238
+ const label = o.ariaLabel ?? (o.content == null && typeof tip === "string" ? plainText(tip) : undefined);
225
239
  // A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
226
240
  // detection here: `attrs` just names another role or variant.
227
241
  A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
228
242
  applyActionBehavior(o, tip != null);
229
- if (o.ariaLabel)
230
- A("aria-label=", o.ariaLabel);
243
+ if (label)
244
+ A("aria-label=", label);
231
245
  // Before the content, so a tooltip the caller adds in there is the later of
232
246
  // the two and wins the hover.
233
- applyTooltipAndKey(tip, o.key, typeof o.content === "string" ? o.content : o.ariaLabel, o.disabled);
247
+ applyTooltipAndKey(o.tooltip, o.key, typeof o.content === "string" ? o.content : label, o.disabled);
234
248
  drawSlot(o.icon);
235
249
  drawSlot(o.content);
236
250
  });
@@ -52,8 +52,9 @@ export declare function isDialogOpen(): boolean;
52
52
  * closes. Lifecycle is also tied to the parent reactive scope — when that scope
53
53
  * is cleaned up the dialog disappears and the promise resolves.
54
54
  *
55
- * Multiple dialogs stack correctly: each new pair (backdrop + dialog) has a
56
- * higher z-index, while older dialogs are pushed behind their covering backdrop.
55
+ * Multiple dialogs stack: each new pair (backdrop + dialog) gets a higher
56
+ * z-index than the one it covers, while older dialogs are pushed behind their
57
+ * covering backdrop.
57
58
  *
58
59
  * @example
59
60
  * ```ts
@@ -4,6 +4,8 @@ import { button } from "./button.js";
4
4
  import { buttonGroup } from "./buttonGroup.js";
5
5
  import { textline } from "./textline.js";
6
6
  import { bindKey, claimKeyboard, keyboardOwner } from "../keys.js";
7
+ /** The layer the bottom dialog's backdrop is painted on; each dialog adds two. */
8
+ const BASE_Z = 200;
7
9
  A.insertGlobalCss({
8
10
  ".s-backdrop": {
9
11
  "&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
@@ -28,6 +30,8 @@ A.insertGlobalCss({
28
30
  });
29
31
  const dialogs = A.proxy({});
30
32
  let dialogCount = 0;
33
+ // `Object.keys` orders integer-like keys numerically, so the last one is the
34
+ // newest dialog — the one on top.
31
35
  const topDialogId = A.derive(() => {
32
36
  const keys = Object.keys(dialogs);
33
37
  if (keys.length)
@@ -38,6 +42,9 @@ export function isDialogOpen() {
38
42
  return topDialogId.value != null;
39
43
  }
40
44
  mountPortal(() => {
45
+ // Sorted numerically: the keys are numbers, and left to `onEach`'s default
46
+ // string ordering "10" would sort before "2", putting the tenth dialog
47
+ // before (hence behind) a still-open second one.
41
48
  A.onEach(dialogs, ({ resolve, opts }, dialogId) => {
42
49
  const close = () => { delete dialogs[dialogId]; };
43
50
  A.clean(() => {
@@ -54,7 +61,7 @@ mountPortal(() => {
54
61
  });
55
62
  // Backdrop - hide when not the top dialog
56
63
  const overlaid = A.derive(() => topDialogId.value != dialogId);
57
- A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
64
+ const backdropEl = A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
58
65
  if (opts.allowCancel !== false)
59
66
  close();
60
67
  });
@@ -105,11 +112,30 @@ mountPortal(() => {
105
112
  }
106
113
  });
107
114
  });
115
+ // Stacking is stated, not left to the order the elements happen to sit in
116
+ // the DOM: a dialog sits a layer above its own backdrop, and that pair a
117
+ // layer above the dialog it covers. (A closing dialog has left the stack
118
+ // already; it keeps the layer it had while it fades out.)
119
+ let depth = 0;
120
+ A(() => {
121
+ const index = Object.keys(dialogs).indexOf(dialogId);
122
+ if (index >= 0)
123
+ depth = index;
124
+ backdropEl.style.zIndex = `${BASE_Z + 2 * depth}`;
125
+ dialogEl.style.zIndex = `${BASE_Z + 2 * depth + 1}`;
126
+ });
108
127
  // Once laid out, move focus into the dialog so it's keyboard-ready and focus
109
128
  // doesn't linger on whatever opened it.
110
- requestAnimationFrame(() => { if (document.body.contains(dialogEl))
111
- focusFirst(dialogEl); });
112
- });
129
+ requestAnimationFrame(() => {
130
+ // Only the top dialog claims focus: one opened *under* another (the
131
+ // covering dialog opened straight after it) must not pull focus into
132
+ // fields the user can't see.
133
+ if (A.peek(() => topDialogId.value) !== dialogId)
134
+ return;
135
+ if (document.body.contains(dialogEl))
136
+ focusFirst(dialogEl);
137
+ });
138
+ }, (_value, dialogId) => +dialogId);
113
139
  });
114
140
  /**
115
141
  * Keep Tab inside a modal dialog: from its last focusable element Tab wraps
@@ -134,8 +160,9 @@ function trapTab(dialogEl, event) {
134
160
  * closes. Lifecycle is also tied to the parent reactive scope — when that scope
135
161
  * is cleaned up the dialog disappears and the promise resolves.
136
162
  *
137
- * Multiple dialogs stack correctly: each new pair (backdrop + dialog) has a
138
- * higher z-index, while older dialogs are pushed behind their covering backdrop.
163
+ * Multiple dialogs stack: each new pair (backdrop + dialog) gets a higher
164
+ * z-index than the one it covers, while older dialogs are pushed behind their
165
+ * covering backdrop.
139
166
  *
140
167
  * @example
141
168
  * ```ts
@@ -28,6 +28,27 @@ export interface FieldOptions {
28
28
  /** Aberdeen attr/style string applied to the control (input) element itself. */
29
29
  inputAttrs?: Attributes;
30
30
  }
31
+ /**
32
+ * Options for a field that can carry content *inside* its control box — an
33
+ * icon against the leading edge, a button against the trailing one. Added by
34
+ * {@link import("./textline").textline} and
35
+ * {@link import("./textarea").textarea}.
36
+ */
37
+ export interface InsetOptions {
38
+ /**
39
+ * Content drawn inside the control, against its leading edge — typically a
40
+ * unit, a currency sign or a search glyph. The control's text is indented to
41
+ * keep clear of it, however wide it turns out to be.
42
+ */
43
+ prefix?: Slot;
44
+ /**
45
+ * Content drawn inside the control, against its trailing edge: a *send*
46
+ * button on a chat box, a clear or reveal button, a character count. Sits
47
+ * against the bottom on a (growing) textarea, and vertically centred on a
48
+ * single-line input.
49
+ */
50
+ suffix?: Slot;
51
+ }
31
52
  /**
32
53
  * Render the standard field chrome (label + control + help/error) around a
33
54
  * caller-supplied control.
@@ -47,3 +68,20 @@ export declare function drawField(opts: FieldOptions, drawControl: (id: string,
47
68
  * each get their own scope so the control element is never recreated.
48
69
  */
49
70
  export declare function applyControlAttrs(opts: FieldOptions, id: string, isInvalid: () => boolean, bind?: Bindable<unknown>): void;
71
+ /**
72
+ * Wrap a control in the {@link InsetOptions} box: the control as drawn by
73
+ * `drawControl`, plus the `prefix`/`suffix` slots laid over its leading and
74
+ * trailing edges.
75
+ *
76
+ * Each inset is measured (it may hold anything) and its width published to the
77
+ * wrapper as a CSS variable, from which the control takes its text padding — so
78
+ * the text never runs under the inset, whatever is in it. Each slot gets its own
79
+ * reactive scope, so appearing, changing or going away never touches the control
80
+ * element (which would lose focus and selection).
81
+ *
82
+ * @param opts The inset slots.
83
+ * @param bottom Align the insets with the control's bottom edge instead of
84
+ * centring them — what a growing textarea wants.
85
+ * @param drawControl Draws the control element itself, marked `s-input`.
86
+ */
87
+ export declare function drawInsets(opts: InsetOptions, bottom: boolean, drawControl: () => void): void;
@@ -16,6 +16,29 @@ A.insertGlobalCss({
16
16
  "&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
17
17
  "&[aria-invalid=true]": "border-color:$s-danger",
18
18
  },
19
+ // Insets sit *over* the control rather than beside it, so the control keeps its
20
+ // own border, focus ring and full width; only its text padding gets out of the
21
+ // way, by however much each inset measures (see `drawInsets`).
22
+ ".s-inset": {
23
+ "&": "position:relative display:grid --s-inset-start:0px --s-inset-end:0px",
24
+ "> .s-input": "padding-inline-start: calc(0.7em + var(--s-inset-start)); padding-inline-end: calc(0.7em + var(--s-inset-end));",
25
+ "> .s-inset_start, > .s-inset_end": "position:absolute top:0 bottom:0 display:flex align-items:center gap:$1 fg:$s-muted",
26
+ "> .s-inset_start": "left:0.35em",
27
+ "> .s-inset_end": "right:0.35em",
28
+ // On a textarea the insets ride the bottom edge, so a growing box keeps its
29
+ // send button where the caret is rather than floating it mid-paragraph.
30
+ "&.s-inset-bottom > .s-inset_start, &.s-inset-bottom > .s-inset_end": "top:auto padding-bottom:0.35em",
31
+ "> .s-inset_start > svg, > .s-inset_end > svg": "width:1.15em height:1.15em",
32
+ // A number input's spinner wants the very corner a trailing inset is in, and
33
+ // it is the inset that was asked for. (Firefox's is `appearance`-controlled.)
34
+ "&:has(> .s-inset_end) > input[type=number]": "appearance:textfield",
35
+ "&:has(> .s-inset_end) > input[type=number]::-webkit-inner-spin-button": "appearance:none margin:0",
36
+ },
37
+ // A plain glyph or a counter shouldn't eat the click that focuses the field;
38
+ // anything the user can actually operate does.
39
+ ".s-inset_start, .s-inset_end": "pointer-events:none",
40
+ ".s-inset_start :where(button, a, input, select, textarea, label, [tabindex])": "pointer-events:auto",
41
+ ".s-inset_end :where(button, a, input, select, textarea, label, [tabindex])": "pointer-events:auto",
19
42
  });
20
43
  /**
21
44
  * Render the standard field chrome (label + control + help/error) around a
@@ -74,3 +97,45 @@ export function applyControlAttrs(opts, id, isInvalid, bind) {
74
97
  if (bind)
75
98
  A("bind=", bind);
76
99
  }
100
+ /**
101
+ * Wrap a control in the {@link InsetOptions} box: the control as drawn by
102
+ * `drawControl`, plus the `prefix`/`suffix` slots laid over its leading and
103
+ * trailing edges.
104
+ *
105
+ * Each inset is measured (it may hold anything) and its width published to the
106
+ * wrapper as a CSS variable, from which the control takes its text padding — so
107
+ * the text never runs under the inset, whatever is in it. Each slot gets its own
108
+ * reactive scope, so appearing, changing or going away never touches the control
109
+ * element (which would lose focus and selection).
110
+ *
111
+ * @param opts The inset slots.
112
+ * @param bottom Align the insets with the control's bottom edge instead of
113
+ * centring them — what a growing textarea wants.
114
+ * @param drawControl Draws the control element itself, marked `s-input`.
115
+ */
116
+ export function drawInsets(opts, bottom, drawControl) {
117
+ A("div.s-inset", () => {
118
+ if (bottom)
119
+ A(".s-inset-bottom");
120
+ drawControl();
121
+ drawInset(() => opts.prefix, "s-inset_start", "--s-inset-start");
122
+ drawInset(() => opts.suffix, "s-inset_end", "--s-inset-end");
123
+ });
124
+ }
125
+ function drawInset(get, cls, cssVar) {
126
+ A(() => {
127
+ const slot = get();
128
+ if (slot == null)
129
+ return;
130
+ // `.small` sizes the buttons inside (as a buttonGroup does), so something
131
+ // inset into a field can never stretch the field.
132
+ const box = A(`div.${cls}.small`, () => drawSlot(slot));
133
+ const wrap = box.parentElement;
134
+ const ro = new ResizeObserver(() => wrap?.style.setProperty(cssVar, `${box.offsetWidth}px`));
135
+ ro.observe(box);
136
+ A.clean(() => {
137
+ ro.disconnect();
138
+ wrap?.style.setProperty(cssVar, "0px");
139
+ });
140
+ });
141
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Bindable } from "../core.js";
2
- import { type FieldOptions } from "./field.js";
2
+ import { type FieldOptions, type InsetOptions } from "./field.js";
3
3
  /** Options for {@link textarea}. */
4
- export interface TextareaOptions extends FieldOptions {
4
+ export interface TextareaOptions extends FieldOptions, InsetOptions {
5
5
  /** Placeholder text. */
6
6
  placeholder?: string;
7
7
  /** Two-way binding target. */
@@ -10,7 +10,12 @@ export interface TextareaOptions extends FieldOptions {
10
10
  value?: string;
11
11
  /** Visible number of text rows. Defaults to `4`. Ignored when `autoGrow` is enabled. */
12
12
  rows?: number;
13
- /** Whether the textarea may be resized by the user. Defaults to `"vertical"`. Ignored when `autoGrow` is enabled. */
13
+ /**
14
+ * Whether the textarea may be resized by the user. Defaults to `"vertical"`,
15
+ * or to `"none"` when a {@link InsetOptions.suffix | suffix} is given — the
16
+ * resize grip and the inset both want the bottom-right corner. Ignored when
17
+ * `autoGrow` is enabled.
18
+ */
14
19
  resize?: "none" | "vertical" | "horizontal" | "both";
15
20
  /** Auto-grow the textarea to fit its content. Defaults to `true`. */
16
21
  autoGrow?: boolean;
@@ -27,6 +32,14 @@ export interface TextareaOptions extends FieldOptions {
27
32
  * ```ts
28
33
  * const $user = A.proxy({bio: ""});
29
34
  * S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
35
+ *
36
+ * // A chat box: the send button sits in the bottom-right corner, inside the field.
37
+ * const $chat = A.proxy({text: ""});
38
+ * S.textarea({
39
+ * placeholder: "Message…",
40
+ * bind: A.ref($chat, "text"),
41
+ * suffix: () => S.iconButton({ icon: send, tooltip: "Send", click: () => post($chat.text) }),
42
+ * });
30
43
  * ```
31
44
  */
32
45
  export declare function textarea(opts?: TextareaOptions): void;