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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/dist/controllers/alert_dialog_controller.js +75 -4
- data/dist/controllers/avatar_controller.js +47 -5
- data/dist/controllers/character_counter_controller.js +338 -63
- data/dist/controllers/command_palette_controller.js +75 -4
- data/dist/controllers/conditional_fields_controller.js +345 -51
- data/dist/controllers/confirm_controller.js +75 -4
- data/dist/controllers/dialog_controller.js +75 -4
- data/dist/controllers/direct_upload_controller.js +201 -45
- data/dist/controllers/dirty_form_controller.js +192 -29
- data/dist/controllers/dismissible_controller.js +83 -18
- data/dist/controllers/drawer_controller.js +75 -4
- data/dist/controllers/focus_controller.js +75 -4
- data/dist/controllers/form_field_controller.js +280 -62
- data/dist/controllers/form_validation_controller.js +208 -83
- data/dist/controllers/number_input_controller.js +47 -5
- data/dist/controllers/overflow_menu_controller.js +2 -1
- data/dist/controllers/pagination_controller.js +2 -1
- data/dist/controllers/persist_controller.js +432 -122
- data/dist/controllers/popover_controller.js +77 -4
- data/dist/controllers/rating_controller.js +9 -5
- data/dist/controllers/scroll_area_controller.js +462 -162
- data/dist/controllers/separator_controller.js +354 -38
- data/dist/controllers/sidebar_controller.js +83 -10
- data/dist/controllers/submit_once_controller.js +399 -121
- data/dist/controllers/theme_controller.js +8 -6
- data/dist/controllers/tree_view_controller.js +2 -1
- data/dist/index.js +2387 -861
- data/lib/stimeo/ui/version.rb +1 -1
- 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
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
this
|
|
20
|
-
this.
|
|
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.
|
|
24
|
-
this
|
|
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
|
-
/**
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
265
|
+
const changed = visible !== previous;
|
|
37
266
|
this.#lastVisible.set(region, visible);
|
|
38
|
-
if (visible)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
102
|
-
#
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
282
|
-
(el) => !el.hidden
|
|
283
|
-
);
|
|
354
|
+
return tabStopsWithin(this.#getContainer());
|
|
284
355
|
}
|
|
285
356
|
};
|
|
286
357
|
|