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
@@ -1,7 +1,134 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/conditional_fields_controller.ts
4
+
5
+ // src/utils/focus_candidate.ts
6
+ function inheritsFieldsetDisabled(control) {
7
+ let fieldset = control.closest("fieldset[disabled]");
8
+ while (fieldset) {
9
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
10
+ if (!legend?.contains(control)) return true;
11
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
12
+ }
13
+ return false;
14
+ }
15
+ function canTakeFocus(element) {
16
+ if (element.closest("[hidden], [inert]")) return false;
17
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
18
+ if (!("disabled" in element)) return true;
19
+ if (element.disabled) return false;
20
+ return !inheritsFieldsetDisabled(element);
21
+ }
22
+
23
+ // src/utils/microtask_coalescer.ts
24
+ var MicrotaskCoalescer = class {
25
+ #run;
26
+ #queued = false;
27
+ #active = false;
28
+ #generation = 0;
29
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
30
+ constructor(run) {
31
+ this.#run = run;
32
+ }
33
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
34
+ activate() {
35
+ this.#active = true;
36
+ }
37
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
38
+ cancel() {
39
+ this.#active = false;
40
+ this.#queued = false;
41
+ this.#generation += 1;
42
+ }
43
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
44
+ schedule() {
45
+ if (!this.#active || this.#queued) return;
46
+ this.#queued = true;
47
+ const generation = this.#generation;
48
+ queueMicrotask(() => {
49
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
50
+ this.#queued = false;
51
+ this.#run();
52
+ });
53
+ }
54
+ };
55
+
56
+ // src/utils/before_cache_reset.ts
57
+ var BeforeCacheReset = class _BeforeCacheReset {
58
+ /** Every subscribed instance, iterated by the one shared document listener. */
59
+ static #subscribers = /* @__PURE__ */ new Set();
60
+ /** The shared listener; installed while at least one instance is subscribed. */
61
+ static #onBeforeCache = () => {
62
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
63
+ };
64
+ #rewind;
65
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
66
+ constructor(rewind) {
67
+ this.#rewind = rewind;
68
+ }
69
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
70
+ activate() {
71
+ const first = _BeforeCacheReset.#subscribers.size === 0;
72
+ _BeforeCacheReset.#subscribers.add(this);
73
+ if (first) {
74
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
75
+ }
76
+ }
77
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
78
+ deactivate() {
79
+ _BeforeCacheReset.#subscribers.delete(this);
80
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
81
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
82
+ }
83
+ };
84
+
85
+ // src/utils/tabindex_loan.ts
86
+ var TabindexLoan = class {
87
+ #value;
88
+ #lent = /* @__PURE__ */ new Set();
89
+ /** Returns live loans before Turbo can copy them into its page snapshot. */
90
+ #beforeCache = new BeforeCacheReset(() => this.returnAll());
91
+ /**
92
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
93
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
94
+ * which a scroll region with no focusable content of its own needs.
95
+ */
96
+ constructor(value = "-1") {
97
+ this.#value = value;
98
+ }
99
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
100
+ lend(element) {
101
+ if (element.hasAttribute("tabindex")) return;
102
+ element.setAttribute("tabindex", this.#value);
103
+ this.#lent.add(element);
104
+ this.#beforeCache.activate();
105
+ }
106
+ /** Takes back every loan whose value is still the one that was lent. */
107
+ returnAll() {
108
+ for (const element of this.#lent) {
109
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
110
+ }
111
+ this.#lent.clear();
112
+ this.#beforeCache.deactivate();
113
+ }
114
+ };
115
+
3
116
  // src/controllers/conditional_fields_controller.ts
4
117
  var DISABLED_MARKER = "data-conditional-disabled";
118
+ var OBSERVED_ATTRIBUTES = [
119
+ "hidden",
120
+ "aria-hidden",
121
+ "data-visible",
122
+ DISABLED_MARKER,
123
+ "disabled",
124
+ "data-when-checked",
125
+ "data-when-unchecked",
126
+ "data-when-value",
127
+ "checked",
128
+ "selected",
129
+ "value",
130
+ "type"
131
+ ];
5
132
  var ConditionalFieldsController = class extends Controller {
6
133
  static targets = ["trigger", "region"];
7
134
  static values = {
@@ -9,56 +136,171 @@ var ConditionalFieldsController = class extends Controller {
9
136
  match: { type: String, default: "any" }
10
137
  };
11
138
  static actions = ["evaluate"];
12
- static events = ["change"];
139
+ static events = ["change", "reconcile"];
140
+ /** Logical state last committed for each current region; rebuilt on every connect. */
13
141
  #lastVisible = /* @__PURE__ */ new WeakMap();
14
- #onChange = () => {
15
- this.evaluate();
142
+ /** Controller writes awaiting an observer callback, separated from authored morphs. */
143
+ #internalWrites = /* @__PURE__ */ new WeakMap();
144
+ #observing = false;
145
+ /** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
146
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
147
+ /** Provides the last-resort focus landmark without claiming an authored tabindex. */
148
+ #rootTabindex = new TabindexLoan();
149
+ /** Watches retained targets and controls whose declarative or reflected state changed. */
150
+ #observer = new MutationObserver((records) => this.#onMutations(records));
151
+ /** Evaluates only events whose source is one of this instance's live trigger targets. */
152
+ #onTriggerInput = (event) => {
153
+ const target = event.target;
154
+ const triggers = this.triggerTargets;
155
+ if (target instanceof Element && triggers.some((trigger) => trigger === target)) {
156
+ this.#commitChange(triggers);
157
+ }
158
+ };
159
+ /** Reconciles property-only retained-element changes after Turbo morphs. */
160
+ #onMorph = () => {
161
+ this.#reconcile.schedule();
162
+ };
163
+ /** Reconciles the settled defaults after a non-cancelled owning form reset. */
164
+ #onReset = (event) => {
165
+ const form = event.target;
166
+ if (form instanceof HTMLFormElement && this.#hasTriggerOwnedBy(form)) {
167
+ queueMicrotask(() => {
168
+ if (!event.defaultPrevented) this.#reconcile.schedule();
169
+ });
170
+ }
16
171
  };
172
+ /** Reflects initial live state and opens every retained-DOM reconciliation path. */
17
173
  connect() {
18
- this.evaluate();
19
- this.element.addEventListener("change", this.#onChange);
20
- this.element.addEventListener("input", this.#onChange);
174
+ this.#lastVisible = /* @__PURE__ */ new WeakMap();
175
+ this.#internalWrites = /* @__PURE__ */ new WeakMap();
176
+ this.#reconcile.activate();
177
+ this.#settle();
178
+ this.#observer.observe(this.element, {
179
+ attributes: true,
180
+ attributeFilter: OBSERVED_ATTRIBUTES,
181
+ childList: true,
182
+ subtree: true
183
+ });
184
+ this.#observing = true;
185
+ this.element.addEventListener("change", this.#onTriggerInput);
186
+ this.element.addEventListener("input", this.#onTriggerInput);
187
+ this.element.addEventListener("turbo:morph-element", this.#onMorph);
188
+ document.addEventListener("reset", this.#onReset, true);
21
189
  }
190
+ /** Releases all external resources and returns the temporary focus landmark loan. */
22
191
  disconnect() {
23
- this.element.removeEventListener("change", this.#onChange);
24
- this.element.removeEventListener("input", this.#onChange);
192
+ this.#reconcile.cancel();
193
+ this.#observing = false;
194
+ this.#observer.disconnect();
195
+ this.element.removeEventListener("change", this.#onTriggerInput);
196
+ this.element.removeEventListener("input", this.#onTriggerInput);
197
+ this.element.removeEventListener("turbo:morph-element", this.#onMorph);
198
+ document.removeEventListener("reset", this.#onReset, true);
199
+ this.#rootTabindex.returnAll();
200
+ }
201
+ /** Reconciles after a trigger target is inserted or restored. */
202
+ triggerTargetConnected() {
203
+ this.#reconcile.schedule();
204
+ }
205
+ /** Reconciles after a trigger target is removed or replaced. */
206
+ triggerTargetDisconnected() {
207
+ this.#reconcile.schedule();
208
+ }
209
+ /** Initializes a region target inserted or restored at runtime. */
210
+ regionTargetConnected() {
211
+ this.#reconcile.schedule();
212
+ }
213
+ /** Reconciles the remaining region set after a target leaves. */
214
+ regionTargetDisconnected() {
215
+ this.#reconcile.schedule();
25
216
  }
26
- /** Re-evaluates every region against the current trigger state. */
217
+ /** Reconciles when application code or a morph changes the match policy. */
218
+ matchValueChanged() {
219
+ this.#reconcile.schedule();
220
+ }
221
+ /** Reconciles control ownership when hidden controls become enabled or excluded. */
222
+ disableHiddenValueChanged() {
223
+ this.#reconcile.schedule();
224
+ }
225
+ /** Re-evaluates every region and repairs all reflected DOM state. */
27
226
  evaluate() {
28
- const triggers = this.triggerTargets;
29
- for (const region of this.regionTargets) {
30
- this.#applyRegion(region, this.#isVisible(region, triggers));
227
+ this.#commitChange();
228
+ }
229
+ /**
230
+ * Computes one settled plan, shows destinations before hiding sources, and then
231
+ * returns logical transitions in DOM order after every region is internally coherent.
232
+ */
233
+ #settle(triggers = this.triggerTargets) {
234
+ const regions = this.regionTargets;
235
+ const plan = /* @__PURE__ */ new Map();
236
+ for (const region of regions) plan.set(region, this.#isVisible(region, triggers));
237
+ const changed = /* @__PURE__ */ new Set();
238
+ const applicationOrder = [
239
+ ...regions.filter((region) => plan.get(region) === true),
240
+ ...regions.filter((region) => plan.get(region) === false)
241
+ ];
242
+ for (const region of applicationOrder) {
243
+ const visible = plan.get(region);
244
+ if (visible !== void 0 && this.#applyRegion(region, visible, plan)) {
245
+ changed.add(region);
246
+ }
247
+ }
248
+ return regions.filter((region) => changed.has(region)).map((region) => ({ region, visible: plan.get(region) === true }));
249
+ }
250
+ /** Reports logical transitions caused by user input or the public action. */
251
+ #commitChange(triggers = this.triggerTargets) {
252
+ for (const detail of this.#settle(triggers)) {
253
+ this.dispatch("change", { detail });
254
+ }
255
+ }
256
+ /** Reports logical transitions derived from DOM, lifecycle, reset, or Value state. */
257
+ #reconcileDom() {
258
+ for (const detail of this.#settle()) {
259
+ this.dispatch("reconcile", { detail });
31
260
  }
32
261
  }
33
- /** Applies a region's visibility, syncing hidden/aria/disabled and emitting change. */
34
- #applyRegion(region, visible) {
262
+ /** Applies one planned state while keeping event history separate from DOM repair. */
263
+ #applyRegion(region, visible, plan) {
35
264
  const previous = this.#lastVisible.get(region) ?? !region.hidden;
36
- if (visible === previous && this.#lastVisible.has(region)) return;
265
+ const changed = visible !== previous;
37
266
  this.#lastVisible.set(region, visible);
38
- if (visible) {
39
- region.hidden = false;
40
- region.removeAttribute("aria-hidden");
41
- region.setAttribute("data-visible", "true");
42
- this.#setRegionDisabled(region, false);
43
- } else {
44
- this.#retreatFocus(region);
45
- region.hidden = true;
46
- region.setAttribute("aria-hidden", "true");
47
- region.removeAttribute("data-visible");
48
- this.#setRegionDisabled(region, true);
49
- }
50
- if (visible !== previous) {
51
- this.dispatch("change", { detail: { region, visible } });
267
+ if (!visible) this.#retreatFocus(region, plan);
268
+ this.#syncRegionState(region, visible);
269
+ this.#syncRegionControls(region, visible);
270
+ return changed;
271
+ }
272
+ /** Reflects visibility idempotently, recording writes so the observer ignores its own work. */
273
+ #syncRegionState(region, visible) {
274
+ this.#writeAttribute(region, "hidden", visible ? null : "");
275
+ this.#writeAttribute(region, "aria-hidden", visible ? null : "true");
276
+ this.#writeAttribute(region, "data-visible", visible ? "true" : null);
277
+ }
278
+ /** Enables/disables descendant controls, touching only state carrying our marker. */
279
+ #syncRegionControls(region, visible) {
280
+ const shouldDisable = !visible && this.disableHiddenValue;
281
+ const controls = region.querySelectorAll("input, textarea, select, button");
282
+ for (const control of controls) {
283
+ const marked = control.hasAttribute(DISABLED_MARKER);
284
+ if (shouldDisable) {
285
+ if (!control.disabled) {
286
+ this.#writeAttribute(control, "disabled", "");
287
+ this.#writeAttribute(control, DISABLED_MARKER, "true");
288
+ } else if (marked && control.getAttribute(DISABLED_MARKER) !== "true") {
289
+ this.#writeAttribute(control, DISABLED_MARKER, "true");
290
+ }
291
+ } else if (marked) {
292
+ this.#writeAttribute(control, "disabled", null);
293
+ this.#writeAttribute(control, DISABLED_MARKER, null);
294
+ }
52
295
  }
53
296
  }
54
297
  /** Whether a region's declared condition holds across `triggers` per `match`. */
55
298
  #isVisible(region, triggers) {
56
299
  const predicate = this.#predicateFor(region);
57
300
  if (predicate === null) return !region.hidden;
58
- if (triggers.length === 0) return false;
59
- return this.matchValue === "all" ? triggers.every(predicate) : triggers.some(predicate);
301
+ return triggers.length > 0 && (this.matchValue === "all" ? triggers.every(predicate) : triggers.some(predicate));
60
302
  }
61
- /** Builds the per-trigger predicate from the region's `data-when-*` attribute. */
303
+ /** Builds the deterministic per-trigger predicate from the region's condition. */
62
304
  #predicateFor(region) {
63
305
  if (region.hasAttribute("data-when-checked")) {
64
306
  return (trigger) => this.#isChecked(trigger);
@@ -75,35 +317,87 @@ var ConditionalFieldsController = class extends Controller {
75
317
  #isChecked(trigger) {
76
318
  return trigger instanceof HTMLInputElement && trigger.checked;
77
319
  }
78
- /** A trigger "has" a value: selected radio/checkbox value, or the control value. */
320
+ /** A trigger has a value when a radio/checkbox is selected, or its control value matches. */
79
321
  #matchesValue(trigger, wanted) {
80
322
  if (trigger instanceof HTMLInputElement && (trigger.type === "checkbox" || trigger.type === "radio")) {
81
323
  return trigger.checked && trigger.value === wanted;
82
324
  }
83
325
  return trigger.value === wanted;
84
326
  }
85
- /** Enables/disables a region's inputs, tracking only the ones we disabled. */
86
- #setRegionDisabled(region, disabled) {
87
- if (!this.disableHiddenValue) return;
88
- const controls = region.querySelectorAll("input, textarea, select, button");
89
- for (const control of Array.from(controls)) {
90
- if (disabled) {
91
- if (!control.disabled) {
92
- control.disabled = true;
93
- control.setAttribute(DISABLED_MARKER, "true");
327
+ /** Moves focus to the first safe trigger, or to the controller landmark as a fallback. */
328
+ #retreatFocus(region, plan) {
329
+ const active = document.activeElement;
330
+ if (active && region.contains(active)) this.#moveFocus(plan);
331
+ }
332
+ /** Moves focus after the caller establishes that the active node is being hidden. */
333
+ #moveFocus(plan) {
334
+ const destination = this.triggerTargets.find(
335
+ (trigger) => canTakeFocus(trigger) && this.#survivesPlan(trigger, plan)
336
+ );
337
+ if (destination) {
338
+ this.#rootTabindex.returnAll();
339
+ destination.focus();
340
+ return;
341
+ }
342
+ this.#rootTabindex.lend(this.element);
343
+ this.element.focus();
344
+ }
345
+ /** Whether a focus candidate sits outside every region this pass will hide. */
346
+ #survivesPlan(candidate, plan) {
347
+ return [...plan].every(([region, visible]) => visible || !region.contains(candidate));
348
+ }
349
+ /** Records one idempotent attribute write before MutationObserver sees it. */
350
+ #writeAttribute(element, name, value) {
351
+ if (element.getAttribute(name) !== value) {
352
+ if (this.#observing) {
353
+ let writes = this.#internalWrites.get(element);
354
+ if (!writes) {
355
+ writes = /* @__PURE__ */ new Map();
356
+ this.#internalWrites.set(element, writes);
94
357
  }
95
- } else if (control.hasAttribute(DISABLED_MARKER)) {
96
- control.disabled = false;
97
- control.removeAttribute(DISABLED_MARKER);
358
+ writes.set(name, value);
98
359
  }
360
+ if (value === null) element.removeAttribute(name);
361
+ else element.setAttribute(name, value);
99
362
  }
100
363
  }
101
- /** Moves focus out of a region about to be hidden, to a trigger when possible. */
102
- #retreatFocus(region) {
103
- const active = document.activeElement;
104
- if (!(active instanceof HTMLElement) || !region.contains(active)) return;
105
- active.blur();
106
- if (this.hasTriggerTarget) this.triggerTargets[0]?.focus();
364
+ /** Separates authored records from controller writes and queues one relevant repair. */
365
+ #onMutations(records) {
366
+ const triggers = this.triggerTargets;
367
+ const regions = this.regionTargets;
368
+ const touchedWrites = /* @__PURE__ */ new Map();
369
+ let needsReconcile = false;
370
+ for (const record of records) {
371
+ if (record.type === "attributes" && record.target instanceof Element) {
372
+ const name = record.attributeName;
373
+ const writes = this.#internalWrites.get(record.target);
374
+ if (name && writes?.has(name)) {
375
+ let names = touchedWrites.get(record.target);
376
+ if (!names) {
377
+ names = /* @__PURE__ */ new Set();
378
+ touchedWrites.set(record.target, names);
379
+ }
380
+ names.add(name);
381
+ if (record.target.getAttribute(name) === writes.get(name)) continue;
382
+ }
383
+ }
384
+ if (record.target instanceof Element && this.#elementAffectsState(record.target, triggers, regions)) {
385
+ needsReconcile = true;
386
+ }
387
+ }
388
+ for (const [element, names] of touchedWrites) {
389
+ const writes = this.#internalWrites.get(element);
390
+ for (const name of names) writes?.delete(name);
391
+ }
392
+ if (needsReconcile) this.#reconcile.schedule();
393
+ }
394
+ /** Whether an authored mutation target belongs to a trigger, region, or managed control. */
395
+ #elementAffectsState(target, triggers, regions) {
396
+ return triggers.some((trigger) => trigger === target || trigger.contains(target)) || regions.some((region) => region === target || region.contains(target));
397
+ }
398
+ /** Whether a form owns at least one current trigger, including `form=` associations. */
399
+ #hasTriggerOwnedBy(form) {
400
+ return this.triggerTargets.some((trigger) => trigger.form === form);
107
401
  }
108
402
  };
109
403
 
@@ -115,8 +115,81 @@ var EscapeLayer = class _EscapeLayer {
115
115
  }
116
116
  };
117
117
 
118
+ // src/utils/focus_candidate.ts
119
+ function inheritsFieldsetDisabled(control) {
120
+ let fieldset = control.closest("fieldset[disabled]");
121
+ while (fieldset) {
122
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
123
+ if (!legend?.contains(control)) return true;
124
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
125
+ }
126
+ return false;
127
+ }
128
+ function canTakeFocus(element) {
129
+ if (element.closest("[hidden], [inert]")) return false;
130
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
131
+ if (!("disabled" in element)) return true;
132
+ if (element.disabled) return false;
133
+ return !inheritsFieldsetDisabled(element);
134
+ }
135
+ var TAB_STOP_CANDIDATE_SELECTOR = [
136
+ "a[href]",
137
+ "area[href]",
138
+ "button",
139
+ "input",
140
+ "select",
141
+ "textarea",
142
+ "summary",
143
+ "iframe",
144
+ "audio[controls]",
145
+ "video[controls]",
146
+ "[tabindex]",
147
+ "[contenteditable]"
148
+ ].join(",");
149
+ function isRenderedForFocus(element) {
150
+ const check = element.checkVisibility;
151
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
152
+ }
153
+ function authoredTabindex(element) {
154
+ const value = element.getAttribute("tabindex");
155
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
156
+ return Number(value);
157
+ }
158
+ function hasNativeTabStop(element) {
159
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
160
+ return element.hasAttribute("href");
161
+ }
162
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
163
+ return true;
164
+ }
165
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
166
+ if (element instanceof HTMLIFrameElement) return true;
167
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
168
+ return element.hasAttribute("controls");
169
+ }
170
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
171
+ const details = element.parentElement;
172
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
173
+ }
174
+ return false;
175
+ }
176
+ function hasEditableTabStop(element) {
177
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
178
+ return value === "" || value === "true" || value === "plaintext-only";
179
+ }
180
+ function isTabStop(element) {
181
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
182
+ const tabindex = authoredTabindex(element);
183
+ if (tabindex !== null) return tabindex >= 0;
184
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
185
+ }
186
+ function tabStopsWithin(root) {
187
+ return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
188
+ isTabStop
189
+ );
190
+ }
191
+
118
192
  // src/utils/focus_trap.ts
119
- var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
120
193
  var FocusTrap = class {
121
194
  /** The element focused before activation, restored on deactivation. */
122
195
  #previouslyFocused = null;
@@ -278,9 +351,7 @@ var FocusTrap = class {
278
351
  }
279
352
  /** Collects the container's currently focusable descendants in DOM order. */
280
353
  #focusableElements() {
281
- return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
282
- (el) => !el.hidden
283
- );
354
+ return tabStopsWithin(this.#getContainer());
284
355
  }
285
356
  };
286
357
 
@@ -115,8 +115,81 @@ var EscapeLayer = class _EscapeLayer {
115
115
  }
116
116
  };
117
117
 
118
+ // src/utils/focus_candidate.ts
119
+ function inheritsFieldsetDisabled(control) {
120
+ let fieldset = control.closest("fieldset[disabled]");
121
+ while (fieldset) {
122
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
123
+ if (!legend?.contains(control)) return true;
124
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
125
+ }
126
+ return false;
127
+ }
128
+ function canTakeFocus(element) {
129
+ if (element.closest("[hidden], [inert]")) return false;
130
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
131
+ if (!("disabled" in element)) return true;
132
+ if (element.disabled) return false;
133
+ return !inheritsFieldsetDisabled(element);
134
+ }
135
+ var TAB_STOP_CANDIDATE_SELECTOR = [
136
+ "a[href]",
137
+ "area[href]",
138
+ "button",
139
+ "input",
140
+ "select",
141
+ "textarea",
142
+ "summary",
143
+ "iframe",
144
+ "audio[controls]",
145
+ "video[controls]",
146
+ "[tabindex]",
147
+ "[contenteditable]"
148
+ ].join(",");
149
+ function isRenderedForFocus(element) {
150
+ const check = element.checkVisibility;
151
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
152
+ }
153
+ function authoredTabindex(element) {
154
+ const value = element.getAttribute("tabindex");
155
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
156
+ return Number(value);
157
+ }
158
+ function hasNativeTabStop(element) {
159
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
160
+ return element.hasAttribute("href");
161
+ }
162
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
163
+ return true;
164
+ }
165
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
166
+ if (element instanceof HTMLIFrameElement) return true;
167
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
168
+ return element.hasAttribute("controls");
169
+ }
170
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
171
+ const details = element.parentElement;
172
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
173
+ }
174
+ return false;
175
+ }
176
+ function hasEditableTabStop(element) {
177
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
178
+ return value === "" || value === "true" || value === "plaintext-only";
179
+ }
180
+ function isTabStop(element) {
181
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
182
+ const tabindex = authoredTabindex(element);
183
+ if (tabindex !== null) return tabindex >= 0;
184
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
185
+ }
186
+ function tabStopsWithin(root) {
187
+ return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
188
+ isTabStop
189
+ );
190
+ }
191
+
118
192
  // src/utils/focus_trap.ts
119
- var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
120
193
  var FocusTrap = class {
121
194
  /** The element focused before activation, restored on deactivation. */
122
195
  #previouslyFocused = null;
@@ -278,9 +351,7 @@ var FocusTrap = class {
278
351
  }
279
352
  /** Collects the container's currently focusable descendants in DOM order. */
280
353
  #focusableElements() {
281
- return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
282
- (el) => !el.hidden
283
- );
354
+ return tabStopsWithin(this.#getContainer());
284
355
  }
285
356
  };
286
357