staffa 0.18.3 → 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.
@@ -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
 
package/src/core.ts CHANGED
@@ -80,6 +80,22 @@ export function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefin
80
80
  /** Selector matching the natively focusable elements we care about. */
81
81
  const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]";
82
82
 
83
+ /** Whether this element can actually take focus right now. */
84
+ const isFocusable = (el: Element): el is HTMLElement =>
85
+ el instanceof HTMLElement &&
86
+ !el.hasAttribute("disabled") &&
87
+ el.getAttribute("aria-disabled") !== "true" &&
88
+ el.tabIndex >= 0 &&
89
+ el.getClientRects().length > 0;
90
+
91
+ /**
92
+ * The focusable elements inside `container`, in tab order — disabled,
93
+ * `aria-disabled`, `tabindex=-1` and hidden ones left out.
94
+ */
95
+ export function focusables(container: HTMLElement): HTMLElement[] {
96
+ return [...container.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
97
+ }
98
+
83
99
  /**
84
100
  * Move keyboard focus to the first focusable element inside `container`, skipping
85
101
  * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
@@ -90,15 +106,9 @@ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]
90
106
  * the DOM and laid out — typically inside a `requestAnimationFrame`.
91
107
  */
92
108
  export function focusFirst(container: HTMLElement, prefer?: string): boolean {
93
- const ok = (el: Element): el is HTMLElement =>
94
- el instanceof HTMLElement &&
95
- !el.hasAttribute("disabled") &&
96
- el.getAttribute("aria-disabled") !== "true" &&
97
- el.tabIndex >= 0 &&
98
- el.getClientRects().length > 0;
99
109
  const target =
100
- (prefer ? [...container.querySelectorAll(prefer)].find(ok) : undefined) ??
101
- [...container.querySelectorAll(FOCUSABLE_SELECTOR)].find(ok);
110
+ (prefer ? [...container.querySelectorAll(prefer)].find(isFocusable) : undefined) ??
111
+ focusables(container)[0];
102
112
  target?.focus();
103
113
  return target != null;
104
114
  }