stimeo-ui 0.6.0 → 0.7.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/dist/controllers/alert_dialog_controller.js +75 -4
  4. data/dist/controllers/avatar_controller.js +47 -5
  5. data/dist/controllers/character_counter_controller.js +338 -63
  6. data/dist/controllers/command_palette_controller.js +75 -4
  7. data/dist/controllers/conditional_fields_controller.js +345 -51
  8. data/dist/controllers/confirm_controller.js +75 -4
  9. data/dist/controllers/dialog_controller.js +75 -4
  10. data/dist/controllers/direct_upload_controller.js +201 -45
  11. data/dist/controllers/dirty_form_controller.js +192 -29
  12. data/dist/controllers/dismissible_controller.js +83 -18
  13. data/dist/controllers/drawer_controller.js +75 -4
  14. data/dist/controllers/focus_controller.js +75 -4
  15. data/dist/controllers/form_field_controller.js +280 -62
  16. data/dist/controllers/form_validation_controller.js +208 -83
  17. data/dist/controllers/number_input_controller.js +47 -5
  18. data/dist/controllers/overflow_menu_controller.js +2 -1
  19. data/dist/controllers/pagination_controller.js +2 -1
  20. data/dist/controllers/persist_controller.js +432 -122
  21. data/dist/controllers/popover_controller.js +77 -4
  22. data/dist/controllers/rating_controller.js +9 -5
  23. data/dist/controllers/scroll_area_controller.js +462 -162
  24. data/dist/controllers/separator_controller.js +354 -38
  25. data/dist/controllers/sidebar_controller.js +83 -10
  26. data/dist/controllers/submit_once_controller.js +399 -121
  27. data/dist/controllers/theme_controller.js +8 -6
  28. data/dist/controllers/tree_view_controller.js +2 -1
  29. data/dist/index.js +2387 -861
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
@@ -2,6 +2,93 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/character_counter_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
22
+ // src/utils/attribute_lease.ts
23
+ var AttributeLease = class {
24
+ #attribute;
25
+ #records = /* @__PURE__ */ new Map();
26
+ /** @param attribute - The attribute whose temporary values this lease owns. */
27
+ constructor(attribute) {
28
+ this.#attribute = attribute;
29
+ }
30
+ /** Writes or removes the leased attribute while preserving its authored value. */
31
+ write(element, value) {
32
+ const existing = this.#records.get(element);
33
+ if (existing) {
34
+ existing.written = value;
35
+ } else {
36
+ this.#records.set(element, {
37
+ original: element.getAttribute(this.#attribute),
38
+ written: value
39
+ });
40
+ }
41
+ this.#reflect(element, value);
42
+ }
43
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
44
+ return(element) {
45
+ const record = this.#records.get(element);
46
+ if (!record) return;
47
+ this.#records.delete(element);
48
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
49
+ if (stillOwned) this.#reflect(element, record.original);
50
+ }
51
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
52
+ #reflect(element, value) {
53
+ if (element.getAttribute(this.#attribute) === value) return;
54
+ if (value === null) element.removeAttribute(this.#attribute);
55
+ else element.setAttribute(this.#attribute, value);
56
+ }
57
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
58
+ returnAll() {
59
+ for (const element of Array.from(this.#records.keys())) this.return(element);
60
+ }
61
+ };
62
+
63
+ // src/utils/before_cache_reset.ts
64
+ var BeforeCacheReset = class _BeforeCacheReset {
65
+ /** Every subscribed instance, iterated by the one shared document listener. */
66
+ static #subscribers = /* @__PURE__ */ new Set();
67
+ /** The shared listener; installed while at least one instance is subscribed. */
68
+ static #onBeforeCache = () => {
69
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
70
+ };
71
+ #rewind;
72
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
73
+ constructor(rewind) {
74
+ this.#rewind = rewind;
75
+ }
76
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
77
+ activate() {
78
+ const first = _BeforeCacheReset.#subscribers.size === 0;
79
+ _BeforeCacheReset.#subscribers.add(this);
80
+ if (first) {
81
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
82
+ }
83
+ }
84
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
85
+ deactivate() {
86
+ _BeforeCacheReset.#subscribers.delete(this);
87
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
88
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
89
+ }
90
+ };
91
+
5
92
  // src/utils/composition_tracker.ts
6
93
  var CompositionTracker = class {
7
94
  #observedTargets = /* @__PURE__ */ new Set();
@@ -49,6 +136,39 @@ var CompositionTracker = class {
49
136
  };
50
137
  };
51
138
 
139
+ // src/utils/microtask_coalescer.ts
140
+ var MicrotaskCoalescer = class {
141
+ #run;
142
+ #queued = false;
143
+ #active = false;
144
+ #generation = 0;
145
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
146
+ constructor(run) {
147
+ this.#run = run;
148
+ }
149
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
150
+ activate() {
151
+ this.#active = true;
152
+ }
153
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
154
+ cancel() {
155
+ this.#active = false;
156
+ this.#queued = false;
157
+ this.#generation += 1;
158
+ }
159
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
160
+ schedule() {
161
+ if (!this.#active || this.#queued) return;
162
+ this.#queued = true;
163
+ const generation = this.#generation;
164
+ queueMicrotask(() => {
165
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
166
+ this.#queued = false;
167
+ this.#run();
168
+ });
169
+ }
170
+ };
171
+
52
172
  // src/utils/safe_timeout.ts
53
173
  var TimerRegistry = class {
54
174
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -103,110 +223,265 @@ var SafeTimeout = class extends TimerRegistry {
103
223
  };
104
224
 
105
225
  // src/controllers/character_counter_controller.ts
106
- var ORIGINAL_INVALID = "data-character-counter-original-invalid";
107
226
  var CharacterCounterController = class _CharacterCounterController extends Controller {
108
227
  static targets = ["input", "output"];
109
228
  static values = {
110
229
  max: { type: Number, default: 0 },
111
230
  warnAt: { type: Number, default: 0 },
112
- mode: { type: String, default: "remaining" }
231
+ mode: { type: String, default: "remaining" },
232
+ announceText: { type: String, default: "" }
113
233
  };
114
- static events = ["change"];
115
- /** Delay (ms) before the live-region count is written, to throttle SR flooding. */
234
+ static events = ["change", "reconcile"];
235
+ /** Delay (ms) before one settled count is sent to the shared announcer. */
116
236
  static #announceDelay = 200;
117
237
  #timeouts = new SafeTimeout();
238
+ #ariaInvalid = new AttributeLease("aria-invalid");
239
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
240
+ #repaint = new MicrotaskCoalescer(() => this.#reconcile(true));
241
+ #composition = new CompositionTracker({
242
+ onEnd: (event) => this.#commitFrom(event.currentTarget)
243
+ });
118
244
  #announceId = null;
119
- /** Owns IME lifecycle state and applies the confirmed count once. */
120
- #composition = new CompositionTracker({ onEnd: () => this.#update() });
245
+ #boundField = null;
246
+ #lastLength = null;
247
+ #lastDetail = null;
248
+ #reflectedOver = null;
121
249
  #onInput = (event) => {
250
+ const field = this.#boundField;
251
+ if (!field || event.currentTarget !== field || this.#field !== field) return;
122
252
  if (this.#composition.isComposing(event)) return;
123
- this.#update();
253
+ this.#commit(field);
124
254
  };
255
+ /** Reflects the current DOM state and opens the mutation-reconciliation window. */
125
256
  connect() {
126
- const field = this.#field;
127
- if (!field) return;
128
- field.addEventListener("input", this.#onInput);
129
- this.#composition.observe(field);
130
- this.#update({ announce: false });
257
+ this.#repaint.activate();
258
+ this.#beforeCache.activate();
259
+ this.#reconcile(false);
131
260
  }
261
+ /** Releases listeners, timers, borrowed ARIA, and controller-owned state hooks. */
132
262
  disconnect() {
133
- const field = this.#field;
134
- field?.removeEventListener("input", this.#onInput);
263
+ this.#repaint.cancel();
264
+ this.#beforeCache.deactivate();
265
+ this.#cancelAnnouncement();
266
+ this.#bindField(null);
135
267
  this.#composition.disconnect();
136
- this.#timeouts.clearAll();
137
- this.#announceId = null;
268
+ this.#clearStateHooks();
269
+ this.#lastLength = null;
270
+ this.#lastDetail = null;
271
+ }
272
+ /** Rebinds and repaints after an input target is added or replaced at runtime. */
273
+ inputTargetConnected() {
274
+ this.#repaint.schedule();
275
+ }
276
+ /** Releases and repaints after an input target is removed or replaced at runtime. */
277
+ inputTargetDisconnected() {
278
+ this.#repaint.schedule();
279
+ }
280
+ /** Initializes a display target inserted after the controller connected. */
281
+ outputTargetConnected() {
282
+ this.#repaint.schedule();
283
+ }
284
+ /** Reconciles the optional display target set after a removal or replacement. */
285
+ outputTargetDisconnected() {
286
+ this.#repaint.schedule();
287
+ }
288
+ /** Repaints when application code or a Turbo morph changes `max`. */
289
+ maxValueChanged() {
290
+ this.#repaint.schedule();
291
+ }
292
+ /** Repaints when application code or a Turbo morph changes `warnAt`. */
293
+ warnAtValueChanged() {
294
+ this.#repaint.schedule();
295
+ }
296
+ /** Repaints when application code or a Turbo morph changes `mode`. */
297
+ modeValueChanged() {
298
+ this.#repaint.schedule();
299
+ }
300
+ /** Cancels a pending old message when its consumer-authored template changes. */
301
+ announceTextValueChanged() {
302
+ this.#repaint.schedule();
303
+ }
304
+ /** Rebinds one mutation batch and reports a changed controller-derived state. */
305
+ #reconcile(report) {
306
+ const previous = this.#lastDetail;
307
+ const owedField = this.#announceId === null ? null : this.#boundField;
308
+ this.#cancelAnnouncement();
309
+ this.#bindField(this.#field);
310
+ if (this.#composition.isComposing()) return;
311
+ const field = this.#boundField;
312
+ if (!field) {
313
+ this.#renderEmpty();
314
+ this.#lastLength = null;
315
+ this.#lastDetail = null;
316
+ return;
317
+ }
318
+ const reading = this.#render(field);
319
+ const detail = this.#detail(reading);
320
+ this.#lastLength = reading.length;
321
+ this.#lastDetail = detail;
322
+ if (owedField === field) this.#scheduleAnnouncement(reading);
323
+ if (report && previous && this.#detailsDiffer(previous, detail)) {
324
+ this.dispatch("reconcile", { detail });
325
+ }
326
+ }
327
+ /** Commits a composition only when it came from the field still owned here. */
328
+ #commitFrom(target) {
329
+ const field = this.#boundField;
330
+ if (!field || target !== field || this.#field !== field) return;
331
+ this.#commit(field);
332
+ }
333
+ /** Reflects one confirmed user input and reports only a real length transition. */
334
+ #commit(field) {
335
+ const reading = this.#render(field);
336
+ const previousLength = this.#lastLength;
337
+ const previousDetail = this.#lastDetail;
338
+ const detail = this.#detail(reading);
339
+ this.#lastLength = reading.length;
340
+ this.#lastDetail = detail;
341
+ if (previousLength === reading.length) {
342
+ if (previousDetail && this.#detailsDiffer(previousDetail, detail)) {
343
+ this.dispatch("reconcile", { detail });
344
+ }
345
+ return;
346
+ }
347
+ this.dispatch("change", { detail });
348
+ this.#scheduleAnnouncement(reading);
349
+ }
350
+ /** Selects the public event state from the richer internal reading. */
351
+ #detail(reading) {
352
+ return {
353
+ length: reading.length,
354
+ remaining: reading.remaining,
355
+ over: reading.over
356
+ };
357
+ }
358
+ /** Compares exactly the state carried by `change` and `reconcile`. */
359
+ #detailsDiffer(left, right) {
360
+ return left.length !== right.length || left.remaining !== right.remaining || left.over !== right.over;
138
361
  }
139
362
  /**
140
- * Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)
141
- * and the `change` event apply immediately; the live-region count text is
142
- * debounced unless `announce` is `false` (initial render).
363
+ * Synchronizes visible count, state hooks, and temporary validation ARIA.
364
+ *
365
+ * @stimeoRenderRoot
143
366
  */
144
- #update(options = {}) {
145
- const field = this.#field;
146
- if (!field) return;
367
+ #render(field) {
147
368
  const length = field.value.length;
148
- const hasLimit = this.maxValue > 0;
149
- const remaining = hasLimit ? this.maxValue - length : null;
150
- const over = hasLimit && length > this.maxValue;
151
- const near = hasLimit && this.warnAtValue > 0 && !over && remaining !== null && remaining <= this.warnAtValue;
369
+ const max = this.#normalizeCount(this.maxValue);
370
+ const warnAt = this.#normalizeCount(this.warnAtValue);
371
+ const remaining = max > 0 ? max - length : null;
372
+ const over = remaining !== null && remaining < 0;
373
+ const near = remaining !== null && warnAt > 0 && !over && remaining <= warnAt;
152
374
  this.#toggle("data-over-limit", over);
153
375
  this.#toggle("data-near-limit", near);
154
- if (hasLimit && over) {
155
- if (!field.hasAttribute(ORIGINAL_INVALID)) {
156
- field.setAttribute(ORIGINAL_INVALID, field.getAttribute("aria-invalid") ?? "");
157
- }
158
- field.setAttribute("aria-invalid", "true");
159
- } else if (field.hasAttribute(ORIGINAL_INVALID)) {
160
- const original = field.getAttribute(ORIGINAL_INVALID);
161
- if (original) {
162
- field.setAttribute("aria-invalid", original);
163
- } else {
164
- field.removeAttribute("aria-invalid");
165
- }
166
- field.removeAttribute(ORIGINAL_INVALID);
167
- }
168
- const text = this.#format(length, remaining);
169
- if (options.announce === false) {
170
- this.#writeOutput(text);
171
- return;
376
+ this.#reflectInvalid(field, over);
377
+ const text = this.#format(length, remaining, max);
378
+ this.#writeOutput(text);
379
+ return { length, remaining, over, max, text };
380
+ }
381
+ /** Clears derived output when the declarative input set has no usable field. */
382
+ #renderEmpty() {
383
+ this.#ariaInvalid.returnAll();
384
+ this.#reflectedOver = null;
385
+ this.#clearStateHooks();
386
+ this.#writeOutput("");
387
+ }
388
+ /** Replaces the observed field symmetrically, returning state from the old one. */
389
+ #bindField(field) {
390
+ if (field === this.#boundField) return;
391
+ const previous = this.#boundField;
392
+ if (previous) {
393
+ previous.removeEventListener("input", this.#onInput);
394
+ this.#composition.unobserve(previous);
395
+ this.#ariaInvalid.return(previous);
172
396
  }
173
- this.dispatch("change", { detail: { length, remaining, over } });
174
- if (this.#announceId !== null) this.#timeouts.clear(this.#announceId);
175
- this.#announceId = this.#timeouts.set(() => {
176
- this.#writeOutput(text);
177
- this.#announceId = null;
178
- }, _CharacterCounterController.#announceDelay);
397
+ this.#boundField = field;
398
+ this.#lastLength = null;
399
+ this.#reflectedOver = null;
400
+ if (!field) return;
401
+ field.addEventListener("input", this.#onInput);
402
+ this.#composition.observe(field);
403
+ }
404
+ /** Leases `aria-invalid` only on the edge into over-limit, then returns it. */
405
+ #reflectInvalid(field, over) {
406
+ if (this.#reflectedOver === over) return;
407
+ this.#reflectedOver = over;
408
+ if (over) this.#ariaInvalid.write(field, "true");
409
+ else this.#ariaInvalid.return(field);
179
410
  }
180
- /** Builds the count text for the active `mode`. */
181
- #format(length, remaining) {
411
+ /** Builds the visible count for the normalized display mode. */
412
+ #format(length, remaining, max) {
182
413
  if (remaining === null) return String(length);
183
- switch (this.modeValue) {
414
+ switch (this.#mode) {
184
415
  case "used":
185
416
  return String(length);
186
417
  case "both":
187
- return `${length}/${this.maxValue}`;
418
+ return `${length}/${max}`;
188
419
  default:
189
420
  return String(remaining);
190
421
  }
191
422
  }
423
+ /** Debounces one i18n-neutral message into the shared polite announcer. */
424
+ #scheduleAnnouncement(reading) {
425
+ this.#cancelAnnouncement();
426
+ const message = fillTemplate(this.announceTextValue, {
427
+ count: reading.text,
428
+ length: reading.length,
429
+ remaining: reading.remaining ?? "",
430
+ max: reading.max,
431
+ over: String(reading.over)
432
+ });
433
+ if (message.trim().length === 0) return;
434
+ this.#announceId = this.#timeouts.set(() => {
435
+ announce(message);
436
+ this.#announceId = null;
437
+ }, _CharacterCounterController.#announceDelay);
438
+ }
439
+ /** Cancels the one outstanding announcement without touching visible output. */
440
+ #cancelAnnouncement() {
441
+ if (this.#announceId !== null) this.#timeouts.clear(this.#announceId);
442
+ this.#announceId = null;
443
+ }
444
+ /** Writes the optional display only when its text actually changed. */
192
445
  #writeOutput(text) {
193
- if (this.hasOutputTarget) this.outputTarget.textContent = text;
446
+ if (!this.hasOutputTarget || this.outputTarget.textContent === text) return;
447
+ this.outputTarget.textContent = text;
194
448
  }
195
- /** Sets a presence-style boolean data hook (attribute present when `on`). */
449
+ /** Reflects a presence-style state hook without redundant attribute writes. */
196
450
  #toggle(name, on) {
197
451
  if (on) {
198
- this.element.setAttribute(name, "true");
199
- } else {
452
+ if (this.element.getAttribute(name) !== "true") this.element.setAttribute(name, "true");
453
+ } else if (this.element.hasAttribute(name)) {
200
454
  this.element.removeAttribute(name);
201
455
  }
202
456
  }
203
- /** The watched field: the `input` target, or the element itself when it is one. */
457
+ /** Removes both state hooks owned by this controller. */
458
+ #clearStateHooks() {
459
+ this.#toggle("data-over-limit", false);
460
+ this.#toggle("data-near-limit", false);
461
+ }
462
+ /** Returns borrowed ARIA and transient hooks before Turbo snapshots the page. */
463
+ #rewindForCache() {
464
+ this.#cancelAnnouncement();
465
+ this.#ariaInvalid.returnAll();
466
+ this.#reflectedOver = null;
467
+ this.#clearStateHooks();
468
+ }
469
+ /** Converts a public count Value into a finite non-negative integer. */
470
+ #normalizeCount(raw) {
471
+ if (!Number.isFinite(raw)) return 0;
472
+ return Math.max(0, Math.trunc(raw));
473
+ }
474
+ /** Falls back to `remaining` when an authored display mode is unknown. */
475
+ get #mode() {
476
+ if (this.modeValue === "used" || this.modeValue === "both") return this.modeValue;
477
+ return "remaining";
478
+ }
479
+ /** The watched field: the first target, or a directly controlled form field. */
204
480
  get #field() {
205
481
  if (this.hasInputTarget) return this.inputTarget;
206
- const el = this.element;
207
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
208
- return el;
209
- }
482
+ const element = this.element;
483
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)
484
+ return element;
210
485
  return null;
211
486
  }
212
487
  };
@@ -180,8 +180,81 @@ var EscapeLayer = class _EscapeLayer {
180
180
  }
181
181
  };
182
182
 
183
+ // src/utils/focus_candidate.ts
184
+ function inheritsFieldsetDisabled(control) {
185
+ let fieldset = control.closest("fieldset[disabled]");
186
+ while (fieldset) {
187
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
188
+ if (!legend?.contains(control)) return true;
189
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
190
+ }
191
+ return false;
192
+ }
193
+ function canTakeFocus(element) {
194
+ if (element.closest("[hidden], [inert]")) return false;
195
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
196
+ if (!("disabled" in element)) return true;
197
+ if (element.disabled) return false;
198
+ return !inheritsFieldsetDisabled(element);
199
+ }
200
+ var TAB_STOP_CANDIDATE_SELECTOR = [
201
+ "a[href]",
202
+ "area[href]",
203
+ "button",
204
+ "input",
205
+ "select",
206
+ "textarea",
207
+ "summary",
208
+ "iframe",
209
+ "audio[controls]",
210
+ "video[controls]",
211
+ "[tabindex]",
212
+ "[contenteditable]"
213
+ ].join(",");
214
+ function isRenderedForFocus(element) {
215
+ const check = element.checkVisibility;
216
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
217
+ }
218
+ function authoredTabindex(element) {
219
+ const value = element.getAttribute("tabindex");
220
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
221
+ return Number(value);
222
+ }
223
+ function hasNativeTabStop(element) {
224
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
225
+ return element.hasAttribute("href");
226
+ }
227
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
228
+ return true;
229
+ }
230
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
231
+ if (element instanceof HTMLIFrameElement) return true;
232
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
233
+ return element.hasAttribute("controls");
234
+ }
235
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
236
+ const details = element.parentElement;
237
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
238
+ }
239
+ return false;
240
+ }
241
+ function hasEditableTabStop(element) {
242
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
243
+ return value === "" || value === "true" || value === "plaintext-only";
244
+ }
245
+ function isTabStop(element) {
246
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
247
+ const tabindex = authoredTabindex(element);
248
+ if (tabindex !== null) return tabindex >= 0;
249
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
250
+ }
251
+ function tabStopsWithin(root) {
252
+ return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
253
+ isTabStop
254
+ );
255
+ }
256
+
183
257
  // src/utils/focus_trap.ts
184
- var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
185
258
  var FocusTrap = class {
186
259
  /** The element focused before activation, restored on deactivation. */
187
260
  #previouslyFocused = null;
@@ -343,9 +416,7 @@ var FocusTrap = class {
343
416
  }
344
417
  /** Collects the container's currently focusable descendants in DOM order. */
345
418
  #focusableElements() {
346
- return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
347
- (el) => !el.hidden
348
- );
419
+ return tabStopsWithin(this.#getContainer());
349
420
  }
350
421
  };
351
422