stimeo-ui 0.4.0 → 0.6.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 +184 -0
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +195 -40
- data/dist/controllers/breadcrumb_controller.js +5 -1
- data/dist/controllers/carousel_controller.js +90 -10
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/clipboard_controller.js +8 -3
- data/dist/controllers/collapsible_controller.js +4 -1
- data/dist/controllers/color_picker_controller.js +41 -11
- data/dist/controllers/context_menu_controller.js +2 -2
- data/dist/controllers/countdown_controller.js +5 -1
- data/dist/controllers/date_range_picker_controller.js +162 -31
- data/dist/controllers/direct_upload_controller.js +3 -3
- data/dist/controllers/empty_state_controller.js +107 -16
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/flash_controller.js +161 -21
- data/dist/controllers/form_validation_controller.js +8 -2
- data/dist/controllers/frame_loading_controller.js +94 -22
- data/dist/controllers/highlight_controller.js +38 -1
- data/dist/controllers/idle_controller.js +39 -6
- data/dist/controllers/local_time_controller.js +2 -0
- data/dist/controllers/masonry_controller.js +1 -1
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/meter_controller.js +3 -1
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/network_status_controller.js +1 -3
- data/dist/controllers/number_input_controller.js +455 -64
- data/dist/controllers/overflow_menu_controller.js +5 -1
- data/dist/controllers/pagination_controller.js +38 -1
- data/dist/controllers/password_strength_controller.js +21 -3
- data/dist/controllers/persist_controller.js +24 -5
- data/dist/controllers/pointer_drag_controller.js +10 -0
- data/dist/controllers/portal_controller.js +10 -0
- data/dist/controllers/progress_controller.js +7 -3
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/range_slider_controller.js +385 -94
- data/dist/controllers/rating_controller.js +274 -89
- data/dist/controllers/relative_time_controller.js +2 -0
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +155 -23
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/separator_controller.js +13 -17
- data/dist/controllers/skeleton_controller.js +71 -3
- data/dist/controllers/slider_controller.js +325 -48
- data/dist/controllers/spinner_controller.js +18 -3
- data/dist/controllers/step_indicator_controller.js +3 -1
- data/dist/controllers/stepper_controller.js +2 -0
- data/dist/controllers/switch_controller.js +162 -18
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/textarea_autosize_controller.js +1 -1
- data/dist/controllers/time_picker_controller.js +296 -104
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +24 -4
- data/dist/index.js +3811 -1048
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -20,6 +20,67 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
20
20
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// src/utils/focus_candidate.ts
|
|
24
|
+
function inheritsFieldsetDisabled(control) {
|
|
25
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
26
|
+
while (fieldset) {
|
|
27
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
28
|
+
if (!legend?.contains(control)) return true;
|
|
29
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/utils/interactive_host.ts
|
|
35
|
+
var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
|
|
36
|
+
function isInteractiveHost(element) {
|
|
37
|
+
if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
|
|
38
|
+
let current = element;
|
|
39
|
+
while (current) {
|
|
40
|
+
const raw = current.getAttribute("contenteditable");
|
|
41
|
+
if (raw !== null) {
|
|
42
|
+
const value = raw.trim().toLowerCase();
|
|
43
|
+
if (value === "false") return false;
|
|
44
|
+
if (value === "" || value === "true" || value === "plaintext-only") return true;
|
|
45
|
+
}
|
|
46
|
+
current = current.parentElement;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/utils/microtask_coalescer.ts
|
|
52
|
+
var MicrotaskCoalescer = class {
|
|
53
|
+
#run;
|
|
54
|
+
#queued = false;
|
|
55
|
+
#active = false;
|
|
56
|
+
#generation = 0;
|
|
57
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
58
|
+
constructor(run) {
|
|
59
|
+
this.#run = run;
|
|
60
|
+
}
|
|
61
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
62
|
+
activate() {
|
|
63
|
+
this.#active = true;
|
|
64
|
+
}
|
|
65
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
66
|
+
cancel() {
|
|
67
|
+
this.#active = false;
|
|
68
|
+
this.#queued = false;
|
|
69
|
+
this.#generation += 1;
|
|
70
|
+
}
|
|
71
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
72
|
+
schedule() {
|
|
73
|
+
if (!this.#active || this.#queued) return;
|
|
74
|
+
this.#queued = true;
|
|
75
|
+
const generation = this.#generation;
|
|
76
|
+
queueMicrotask(() => {
|
|
77
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
78
|
+
this.#queued = false;
|
|
79
|
+
this.#run();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
23
84
|
// src/utils/roving_tabindex.ts
|
|
24
85
|
var RovingTabindex = class {
|
|
25
86
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -42,10 +103,12 @@ var RovingTabindex = class {
|
|
|
42
103
|
* "nothing is currently tabbable".
|
|
43
104
|
*
|
|
44
105
|
* @param index - Position of the item to make tabbable.
|
|
45
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item
|
|
106
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
107
|
+
* and `items` to reuse an event-scoped collection snapshot.
|
|
46
108
|
*/
|
|
47
|
-
setActive(index,
|
|
48
|
-
const
|
|
109
|
+
setActive(index, options = {}) {
|
|
110
|
+
const { focus = false } = options;
|
|
111
|
+
const items = options.items ?? this.#getItems();
|
|
49
112
|
items.forEach((item, i) => {
|
|
50
113
|
item.tabIndex = i === index ? 0 : -1;
|
|
51
114
|
});
|
|
@@ -59,79 +122,500 @@ function rovingMove(current, length, delta, wrap) {
|
|
|
59
122
|
}
|
|
60
123
|
|
|
61
124
|
// src/controllers/radio_group_controller.ts
|
|
125
|
+
var OBSERVED_ATTRIBUTES = [
|
|
126
|
+
"aria-checked",
|
|
127
|
+
"aria-disabled",
|
|
128
|
+
"contenteditable",
|
|
129
|
+
"controls",
|
|
130
|
+
"data-value",
|
|
131
|
+
"disabled",
|
|
132
|
+
"hidden",
|
|
133
|
+
"href",
|
|
134
|
+
"tabindex",
|
|
135
|
+
"type",
|
|
136
|
+
"value"
|
|
137
|
+
];
|
|
138
|
+
var hasModifier = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
62
139
|
var RadioGroupController = class extends Controller {
|
|
63
140
|
static targets = ["radio", "field"];
|
|
64
141
|
static actions = ["onKeydown", "select"];
|
|
65
|
-
static events = ["change"];
|
|
66
|
-
#roving = new RovingTabindex(() => this
|
|
67
|
-
|
|
142
|
+
static events = ["change", "reconcile"];
|
|
143
|
+
#roving = new RovingTabindex(() => this.#managedTargets);
|
|
144
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
145
|
+
#handledEvents = /* @__PURE__ */ new WeakSet();
|
|
146
|
+
#managedRadios = /* @__PURE__ */ new Set();
|
|
147
|
+
#originalTabindex = /* @__PURE__ */ new Map();
|
|
148
|
+
#ownedChecked = /* @__PURE__ */ new Set();
|
|
149
|
+
#internalCheckedValues = /* @__PURE__ */ new Map();
|
|
150
|
+
#internalTabindexValues = /* @__PURE__ */ new Map();
|
|
151
|
+
#observer = null;
|
|
152
|
+
#committedRadio = null;
|
|
153
|
+
#connected = false;
|
|
154
|
+
#lastOrder = [];
|
|
155
|
+
#focusedRadio = null;
|
|
156
|
+
#pendingFocusIndex = null;
|
|
157
|
+
#preferChecked = false;
|
|
158
|
+
/**
|
|
159
|
+
* Normalizes authored selection, establishes the APG Tab entry point, and
|
|
160
|
+
* reflects derived form state without reporting a user edit.
|
|
161
|
+
*/
|
|
68
162
|
connect() {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
163
|
+
const currentRadios = new Set(this.radioTargets);
|
|
164
|
+
for (const radio of this.#managedRadios) {
|
|
165
|
+
if (!currentRadios.has(radio)) this.#releaseRadio(radio);
|
|
166
|
+
}
|
|
167
|
+
for (const radio of this.radioTargets) this.#reconcileHost(radio, false);
|
|
168
|
+
this.#normalizeSelection();
|
|
169
|
+
this.#ensureTabStop(true);
|
|
170
|
+
this.#reflectField(this.#selectedRadio, { silent: true });
|
|
171
|
+
this.#committedRadio = this.#selectedRadio ?? null;
|
|
172
|
+
this.#lastOrder = this.#managedTargets;
|
|
173
|
+
this.element.addEventListener("click", this.#onClickCapture, true);
|
|
174
|
+
this.element.addEventListener("keydown", this.#onKeydownCapture, true);
|
|
175
|
+
this.element.addEventListener("click", this.#onClick);
|
|
176
|
+
this.element.addEventListener("keydown", this.#onKeydown);
|
|
177
|
+
this.element.addEventListener("focusin", this.#onFocusin);
|
|
178
|
+
this.element.addEventListener("focusout", this.#onFocusout);
|
|
179
|
+
this.#connected = true;
|
|
180
|
+
this.#preferChecked = false;
|
|
181
|
+
this.#internalCheckedValues.clear();
|
|
182
|
+
this.#internalTabindexValues.clear();
|
|
183
|
+
this.#reconcile.activate();
|
|
184
|
+
this.#observeMutations();
|
|
185
|
+
}
|
|
186
|
+
/** Releases every listener, observer, and queued pass while retaining live DOM state. */
|
|
187
|
+
disconnect() {
|
|
188
|
+
this.#connected = false;
|
|
189
|
+
this.#reconcile.cancel();
|
|
190
|
+
this.element.removeEventListener("click", this.#onClickCapture, true);
|
|
191
|
+
this.element.removeEventListener("keydown", this.#onKeydownCapture, true);
|
|
192
|
+
this.element.removeEventListener("click", this.#onClick);
|
|
193
|
+
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
194
|
+
this.element.removeEventListener("focusin", this.#onFocusin);
|
|
195
|
+
this.element.removeEventListener("focusout", this.#onFocusout);
|
|
196
|
+
this.#observer?.disconnect();
|
|
197
|
+
this.#observer = null;
|
|
198
|
+
this.#internalCheckedValues.clear();
|
|
199
|
+
this.#internalTabindexValues.clear();
|
|
200
|
+
this.#focusedRadio = null;
|
|
201
|
+
this.#pendingFocusIndex = null;
|
|
202
|
+
this.#committedRadio = null;
|
|
203
|
+
this.#preferChecked = false;
|
|
204
|
+
}
|
|
205
|
+
/** Removes a newly connected radio from the Tab sequence before batch reconciliation. */
|
|
206
|
+
radioTargetConnected(radio) {
|
|
207
|
+
if (this.#connected === false) return;
|
|
208
|
+
this.#reconcileHost(radio, true);
|
|
209
|
+
this.#preferChecked = true;
|
|
210
|
+
this.#reconcile.schedule();
|
|
211
|
+
}
|
|
212
|
+
/** Releases target-only attributes and repairs selection, form state, focus, and roving. */
|
|
213
|
+
radioTargetDisconnected(radio) {
|
|
214
|
+
if (!this.#connected) return;
|
|
215
|
+
if (this.#focusedRadio === radio) {
|
|
216
|
+
this.#pendingFocusIndex = this.#lastOrder.indexOf(radio);
|
|
217
|
+
this.#focusedRadio = null;
|
|
218
|
+
}
|
|
219
|
+
this.#releaseRadio(radio);
|
|
220
|
+
this.#preferChecked = true;
|
|
221
|
+
this.#reconcile.schedule();
|
|
72
222
|
}
|
|
73
|
-
/**
|
|
223
|
+
/** Reflects the current selection into a field added or replaced at runtime. */
|
|
224
|
+
fieldTargetConnected() {
|
|
225
|
+
this.#reconcile.schedule();
|
|
226
|
+
}
|
|
227
|
+
/** Reconciles after a field target is removed from a retained group. */
|
|
228
|
+
fieldTargetDisconnected() {
|
|
229
|
+
this.#reconcile.schedule();
|
|
230
|
+
}
|
|
231
|
+
/** Selects the action's radio. Per-radio action wiring is optional. */
|
|
74
232
|
select(event) {
|
|
75
|
-
|
|
76
|
-
|
|
233
|
+
if (event.defaultPrevented || !this.#ownsEventTarget(event.target)) return;
|
|
234
|
+
const radio = event.currentTarget;
|
|
235
|
+
if (!radio || !this.radioTargets.includes(radio)) return;
|
|
236
|
+
this.#handledEvents.add(event);
|
|
237
|
+
this.#selectRadio(radio, { focus: false });
|
|
77
238
|
}
|
|
78
|
-
/**
|
|
239
|
+
/** Applies the APG key map to the action's radio. Per-radio action wiring is optional. */
|
|
79
240
|
onKeydown(event) {
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
241
|
+
const radio = event.currentTarget;
|
|
242
|
+
if (!radio || !this.radioTargets.includes(radio)) return;
|
|
243
|
+
this.#handleKeydown(event, radio);
|
|
244
|
+
}
|
|
245
|
+
/** Blocks disabled pointer activation before per-radio and consumer handlers run. */
|
|
246
|
+
#onClickCapture = (event) => {
|
|
247
|
+
const radio = this.#radioForEventTarget(event.target);
|
|
248
|
+
if (!radio || !this.#isSupportedHost(radio) || !this.#isActivationDisabled(radio)) return;
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
event.stopImmediatePropagation();
|
|
251
|
+
};
|
|
252
|
+
/** Blocks disabled activation and suppresses a button's non-APG Enter activation. */
|
|
253
|
+
#onKeydownCapture = (event) => {
|
|
254
|
+
if (event.key !== " " && event.key !== "Enter") return;
|
|
255
|
+
const radio = this.#radioForEventTarget(event.target);
|
|
256
|
+
if (!radio || !this.#isSupportedHost(radio)) return;
|
|
257
|
+
if (this.#isActivationDisabled(radio) || event.key === "Enter" && radio instanceof HTMLButtonElement) {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
event.stopImmediatePropagation();
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
/** Delegated click path for static and runtime-added radios without actions. */
|
|
263
|
+
#onClick = (event) => {
|
|
264
|
+
if (this.#handledEvents.delete(event) || event.defaultPrevented) return;
|
|
265
|
+
const radio = this.#radioForEventTarget(event.target);
|
|
266
|
+
if (radio) this.#selectRadio(radio, { focus: false });
|
|
267
|
+
};
|
|
268
|
+
/** Delegated keydown path for static and runtime-added radios without actions. */
|
|
269
|
+
#onKeydown = (event) => {
|
|
270
|
+
const radio = this.#radioForEventTarget(event.target);
|
|
271
|
+
if (radio) this.#handleKeydown(event, radio);
|
|
272
|
+
};
|
|
273
|
+
/** Keeps programmatic and pointer focus as the group's current roving position. */
|
|
274
|
+
#onFocusin = (event) => {
|
|
275
|
+
const radio = this.#radioForEventTarget(event.target);
|
|
276
|
+
if (!radio || !this.#isNavigable(radio)) return;
|
|
277
|
+
this.#focusedRadio = radio;
|
|
278
|
+
this.#setActive(radio);
|
|
279
|
+
};
|
|
280
|
+
/** Distinguishes ordinary focus departure from focus lost because its radio was removed. */
|
|
281
|
+
#onFocusout = (event) => {
|
|
282
|
+
const radio = this.#radioForEventTarget(event.target);
|
|
283
|
+
if (!radio) return;
|
|
284
|
+
queueMicrotask(() => {
|
|
285
|
+
if (!this.#connected || this.#focusedRadio !== radio || !radio.isConnected) return;
|
|
286
|
+
const active = document.activeElement;
|
|
287
|
+
if (!(active instanceof Node) || !radio.contains(active)) this.#focusedRadio = null;
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
/** Applies navigation and Space activation to one supported radio. */
|
|
291
|
+
#handleKeydown(event, radio) {
|
|
292
|
+
if (event.defaultPrevented || !this.#isSupportedHost(radio)) return;
|
|
293
|
+
if (event.isComposing || isReservedArrowChord(event)) return;
|
|
294
|
+
if ((event.key === "Home" || event.key === "End") && hasModifier(event)) return;
|
|
295
|
+
if (event.key === " ") {
|
|
296
|
+
if (this.#isActivationDisabled(radio)) {
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
event.stopImmediatePropagation();
|
|
103
299
|
return;
|
|
300
|
+
}
|
|
301
|
+
if (radio instanceof HTMLButtonElement) {
|
|
302
|
+
if (event.repeat) event.preventDefault();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
event.preventDefault();
|
|
306
|
+
if (!event.repeat) this.#selectRadio(radio, { focus: true });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const items = this.#managedTargets;
|
|
310
|
+
const current = items.indexOf(radio);
|
|
311
|
+
const step = logicalArrowStep(event.key, this.element);
|
|
312
|
+
let destination;
|
|
313
|
+
if (step !== 0) {
|
|
314
|
+
destination = this.#nextNavigable(current, step);
|
|
315
|
+
} else if (event.key === "Home") {
|
|
316
|
+
destination = this.#navigableRadios[0];
|
|
317
|
+
} else if (event.key === "End") {
|
|
318
|
+
const navigable = this.#navigableRadios;
|
|
319
|
+
destination = navigable[navigable.length - 1];
|
|
320
|
+
} else {
|
|
321
|
+
return;
|
|
104
322
|
}
|
|
105
323
|
event.preventDefault();
|
|
106
|
-
|
|
324
|
+
if (!destination) return;
|
|
325
|
+
if (this.#isActivationDisabled(destination)) this.#setActive(destination, true);
|
|
326
|
+
else this.#selectRadio(destination, { focus: true });
|
|
327
|
+
}
|
|
328
|
+
/** Applies one user selection and emits only when selected identity changes. */
|
|
329
|
+
#selectRadio(radio, { focus }) {
|
|
330
|
+
if (!this.#isSupportedHost(radio) || this.#isActivationDisabled(radio)) return;
|
|
331
|
+
const previous = this.#selectedRadio;
|
|
332
|
+
const changed = previous !== radio;
|
|
333
|
+
if (changed) {
|
|
334
|
+
for (const item of this.#managedTargets) this.#setChecked(item, item === radio);
|
|
335
|
+
}
|
|
336
|
+
this.#setActive(radio, focus);
|
|
337
|
+
this.#reflectField(radio, { silent: !changed });
|
|
338
|
+
this.#committedRadio = radio;
|
|
339
|
+
if (changed) {
|
|
340
|
+
this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Reconciles target ownership, selection, roving, focus continuity, and form state. */
|
|
344
|
+
#reconcileDom() {
|
|
345
|
+
const observer = this.#observer;
|
|
346
|
+
observer?.disconnect();
|
|
347
|
+
for (const radio of this.radioTargets) this.#reconcileHost(radio, true);
|
|
348
|
+
this.#normalizeSelection();
|
|
349
|
+
const pendingFocusIndex = this.#pendingFocusIndex;
|
|
350
|
+
this.#pendingFocusIndex = null;
|
|
351
|
+
if (pendingFocusIndex !== null) {
|
|
352
|
+
const destination = this.#nearestNavigable(pendingFocusIndex);
|
|
353
|
+
this.#setActive(destination, destination !== null);
|
|
354
|
+
} else {
|
|
355
|
+
this.#ensureTabStop(this.#preferChecked);
|
|
356
|
+
}
|
|
357
|
+
this.#reflectField(this.#selectedRadio, { silent: true });
|
|
358
|
+
this.#lastOrder = this.#managedTargets;
|
|
359
|
+
this.#preferChecked = false;
|
|
360
|
+
this.#internalCheckedValues.clear();
|
|
361
|
+
this.#internalTabindexValues.clear();
|
|
362
|
+
if (this.#connected && observer) this.#observeWith(observer);
|
|
363
|
+
this.#reportReconciledSelection();
|
|
107
364
|
}
|
|
108
365
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
366
|
+
* Announces a selection this pass decided rather than the user. Dispatched after
|
|
367
|
+
* observation resumes so a consumer's own DOM edits are seen by the next pass.
|
|
111
368
|
*/
|
|
112
|
-
#
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
this
|
|
116
|
-
|
|
369
|
+
#reportReconciledSelection() {
|
|
370
|
+
const settled = this.#selectedRadio ?? null;
|
|
371
|
+
if (settled === this.#committedRadio) return;
|
|
372
|
+
this.#committedRadio = settled;
|
|
373
|
+
this.dispatch("reconcile", {
|
|
374
|
+
detail: { value: settled ? this.#radioValue(settled) : "", radio: settled }
|
|
117
375
|
});
|
|
118
|
-
this.#roving.setActive(index, { focus });
|
|
119
|
-
this.#reflectField(radio);
|
|
120
|
-
this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
|
|
121
376
|
}
|
|
122
|
-
/**
|
|
123
|
-
#
|
|
124
|
-
|
|
377
|
+
/** Begins or ends ownership according to a radio's current host semantics. */
|
|
378
|
+
#reconcileHost(radio, dropFromTabSequence) {
|
|
379
|
+
if (!this.#isSupportedHost(radio)) {
|
|
380
|
+
this.#releaseRadio(radio);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (!this.#managedRadios.has(radio)) {
|
|
384
|
+
this.#managedRadios.add(radio);
|
|
385
|
+
this.#originalTabindex.set(radio, radio.getAttribute("tabindex"));
|
|
386
|
+
if (dropFromTabSequence) this.#writeTabindex(radio, -1);
|
|
387
|
+
}
|
|
388
|
+
this.#normalizeChecked(radio);
|
|
389
|
+
}
|
|
390
|
+
/** Restores only defaults owned while an element belonged to this group. */
|
|
391
|
+
#releaseRadio(radio) {
|
|
392
|
+
this.#managedRadios.delete(radio);
|
|
393
|
+
const original = this.#originalTabindex.get(radio);
|
|
394
|
+
if (original === null) {
|
|
395
|
+
this.#markInternal(this.#internalTabindexValues, radio, null);
|
|
396
|
+
radio.removeAttribute("tabindex");
|
|
397
|
+
} else if (original !== void 0) {
|
|
398
|
+
this.#markInternal(this.#internalTabindexValues, radio, original);
|
|
399
|
+
radio.setAttribute("tabindex", original);
|
|
400
|
+
}
|
|
401
|
+
this.#originalTabindex.delete(radio);
|
|
402
|
+
if (this.#ownedChecked.delete(radio)) {
|
|
403
|
+
this.#markInternal(this.#internalCheckedValues, radio, null);
|
|
404
|
+
radio.removeAttribute("aria-checked");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** Supplies a missing checked state and normalizes invalid ARIA tokens. */
|
|
408
|
+
#normalizeChecked(radio) {
|
|
409
|
+
const value = radio.getAttribute("aria-checked");
|
|
410
|
+
if (value === null) {
|
|
411
|
+
this.#ownedChecked.add(radio);
|
|
412
|
+
this.#setChecked(radio, false);
|
|
413
|
+
} else if (value !== "true" && value !== "false") {
|
|
414
|
+
this.#setChecked(radio, false);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** Makes the first DOM-ordered checked radio the sole checked radio. */
|
|
418
|
+
#normalizeSelection() {
|
|
419
|
+
let found = false;
|
|
420
|
+
for (const radio of this.#managedTargets) {
|
|
421
|
+
this.#normalizeChecked(radio);
|
|
422
|
+
if (!this.#isChecked(radio)) continue;
|
|
423
|
+
if (!found) found = true;
|
|
424
|
+
else this.#setChecked(radio, false);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/** Keeps one navigable Tab stop, preferring checked state when selection changed externally. */
|
|
428
|
+
#ensureTabStop(preferChecked) {
|
|
429
|
+
const navigable = this.#navigableRadios;
|
|
430
|
+
let active;
|
|
431
|
+
if (!preferChecked) {
|
|
432
|
+
const current = this.#managedTargets.filter(
|
|
433
|
+
(radio) => radio.tabIndex === 0 && this.#isNavigable(radio)
|
|
434
|
+
);
|
|
435
|
+
if (current.length === 1) active = current[0];
|
|
436
|
+
}
|
|
437
|
+
const selected = this.#selectedRadio;
|
|
438
|
+
if (!active && selected && this.#isNavigable(selected)) active = selected;
|
|
439
|
+
active ??= navigable[0];
|
|
440
|
+
this.#setActive(active ?? null);
|
|
125
441
|
}
|
|
126
442
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
443
|
+
* The surviving navigable radio closest to a position in the order captured
|
|
444
|
+
* before the removal: the first one at or after it, else the last one before it.
|
|
445
|
+
* The saved position and this search read the same population, so a `hidden` or
|
|
446
|
+
* natively disabled sibling never shifts the destination past its neighbor.
|
|
131
447
|
*/
|
|
448
|
+
#nearestNavigable(index) {
|
|
449
|
+
const navigable = new Set(this.#navigableRadios);
|
|
450
|
+
for (let i = index; i < this.#lastOrder.length; i++) {
|
|
451
|
+
const radio = this.#lastOrder[i];
|
|
452
|
+
if (radio && navigable.has(radio)) return radio;
|
|
453
|
+
}
|
|
454
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
455
|
+
const radio = this.#lastOrder[i];
|
|
456
|
+
if (radio && navigable.has(radio)) return radio;
|
|
457
|
+
}
|
|
458
|
+
return this.#navigableRadios[0] ?? null;
|
|
459
|
+
}
|
|
460
|
+
/** Moves through full DOM order until a navigable radio is found. */
|
|
461
|
+
#nextNavigable(fromIndex, delta) {
|
|
462
|
+
const items = this.#managedTargets;
|
|
463
|
+
let index = fromIndex;
|
|
464
|
+
for (let step = 0; step < items.length; step++) {
|
|
465
|
+
index = rovingMove(index, items.length, delta);
|
|
466
|
+
const candidate = items[index];
|
|
467
|
+
if (candidate && this.#isNavigable(candidate)) return candidate;
|
|
468
|
+
}
|
|
469
|
+
return void 0;
|
|
470
|
+
}
|
|
471
|
+
/** Assigns the single roving Tab stop and optionally moves DOM focus. */
|
|
472
|
+
#setActive(radio, focus = false) {
|
|
473
|
+
const items = this.#managedTargets;
|
|
474
|
+
const index = items.indexOf(radio);
|
|
475
|
+
this.#roving.setActive(index, { focus });
|
|
476
|
+
for (const item of items) {
|
|
477
|
+
this.#markInternal(this.#internalTabindexValues, item, item.getAttribute("tabindex"));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/** Finds this group's radio containing an event target, excluding nested groups. */
|
|
481
|
+
#radioForEventTarget(target) {
|
|
482
|
+
if (!this.#ownsEventTarget(target)) return void 0;
|
|
483
|
+
const node = target;
|
|
484
|
+
return this.radioTargets.find((radio) => radio === node || radio.contains(node));
|
|
485
|
+
}
|
|
486
|
+
/** Whether the closest Radio Group scope around a target is this instance. */
|
|
487
|
+
#ownsEventTarget(target) {
|
|
488
|
+
return target instanceof Element && target.closest('[data-controller~="stimeo--radio-group"]') === this.element;
|
|
489
|
+
}
|
|
490
|
+
/** Hosts whose activation model can be owned without conflicting native behavior. */
|
|
491
|
+
#isSupportedHost(radio) {
|
|
492
|
+
if (radio instanceof HTMLButtonElement) return radio.type === "button";
|
|
493
|
+
return !isInteractiveHost(radio);
|
|
494
|
+
}
|
|
495
|
+
/** Radios eligible for roving focus; ARIA-disabled deliberately remains eligible. */
|
|
496
|
+
#isNavigable(radio) {
|
|
497
|
+
if (!this.#isSupportedHost(radio) || this.#isHidden(radio)) return false;
|
|
498
|
+
if (!(radio instanceof HTMLButtonElement)) return true;
|
|
499
|
+
return !radio.disabled && !inheritsFieldsetDisabled(radio);
|
|
500
|
+
}
|
|
501
|
+
/** Whether hidden applies on the path from a radio up to the group root. */
|
|
502
|
+
#isHidden(radio) {
|
|
503
|
+
let current = radio;
|
|
504
|
+
while (current && current !== this.element) {
|
|
505
|
+
if (current.hasAttribute("hidden")) return true;
|
|
506
|
+
current = current.parentElement;
|
|
507
|
+
}
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
/** Whether ARIA, visibility, or native HTML semantics suppress selection. */
|
|
511
|
+
#isActivationDisabled(radio) {
|
|
512
|
+
if (!this.#isNavigable(radio)) return true;
|
|
513
|
+
let current = radio;
|
|
514
|
+
while (current) {
|
|
515
|
+
if (current.getAttribute("aria-disabled") === "true") return true;
|
|
516
|
+
current = current.parentElement;
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
/** Distinguishes external retained-element changes from this controller's own writes. */
|
|
521
|
+
#handleMutationRecords(records) {
|
|
522
|
+
let external = false;
|
|
523
|
+
for (const record of records) {
|
|
524
|
+
const target = record.target;
|
|
525
|
+
const attribute = record.attributeName;
|
|
526
|
+
if (attribute === "aria-checked") {
|
|
527
|
+
const value = target.getAttribute(attribute);
|
|
528
|
+
if (this.#matchesInternal(this.#internalCheckedValues, target, value)) continue;
|
|
529
|
+
this.#ownedChecked.delete(target);
|
|
530
|
+
this.#preferChecked = true;
|
|
531
|
+
external = true;
|
|
532
|
+
} else if (attribute === "tabindex") {
|
|
533
|
+
const value = target.getAttribute(attribute);
|
|
534
|
+
if (this.#matchesInternal(this.#internalTabindexValues, target, value)) continue;
|
|
535
|
+
if (this.#managedRadios.has(target)) this.#originalTabindex.set(target, value);
|
|
536
|
+
external = true;
|
|
537
|
+
} else {
|
|
538
|
+
external = true;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
this.#internalCheckedValues.clear();
|
|
542
|
+
this.#internalTabindexValues.clear();
|
|
543
|
+
if (external) this.#reconcile.schedule();
|
|
544
|
+
}
|
|
545
|
+
/** Records an attribute write this pass made, keeping one claim per record it will produce. */
|
|
546
|
+
#markInternal(values, target, value) {
|
|
547
|
+
const pending = values.get(target);
|
|
548
|
+
if (pending) {
|
|
549
|
+
pending.value = value;
|
|
550
|
+
pending.count += 1;
|
|
551
|
+
} else {
|
|
552
|
+
values.set(target, { value, count: 1 });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** Consumes one claim on a record whose value this pass wrote. */
|
|
556
|
+
#matchesInternal(values, target, value) {
|
|
557
|
+
const pending = values.get(target);
|
|
558
|
+
if (!pending || pending.value !== value) return false;
|
|
559
|
+
pending.count -= 1;
|
|
560
|
+
if (pending.count === 0) values.delete(target);
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
/** Watches target membership, retained state/hosts, form state, and fieldset ancestry. */
|
|
564
|
+
#observeMutations() {
|
|
565
|
+
const observer = new MutationObserver((records) => this.#handleMutationRecords(records));
|
|
566
|
+
this.#observer = observer;
|
|
567
|
+
this.#observeWith(observer);
|
|
568
|
+
}
|
|
569
|
+
/** Registers root and ancestor observation on one observer instance. */
|
|
570
|
+
#observeWith(observer) {
|
|
571
|
+
observer.observe(this.element, {
|
|
572
|
+
subtree: true,
|
|
573
|
+
childList: true,
|
|
574
|
+
attributes: true,
|
|
575
|
+
attributeFilter: OBSERVED_ATTRIBUTES
|
|
576
|
+
});
|
|
577
|
+
let ancestor = this.element.parentElement;
|
|
578
|
+
while (ancestor) {
|
|
579
|
+
observer.observe(ancestor, {
|
|
580
|
+
attributes: true,
|
|
581
|
+
attributeFilter: ["contenteditable", "disabled"]
|
|
582
|
+
});
|
|
583
|
+
ancestor = ancestor.parentElement;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** Supported targets currently managed by the roving primitive. */
|
|
587
|
+
get #managedTargets() {
|
|
588
|
+
return this.radioTargets.filter((radio) => this.#managedRadios.has(radio));
|
|
589
|
+
}
|
|
590
|
+
/** Managed radios eligible for the roving Tab stop. */
|
|
591
|
+
get #navigableRadios() {
|
|
592
|
+
return this.#managedTargets.filter((radio) => this.#isNavigable(radio));
|
|
593
|
+
}
|
|
594
|
+
/** The currently checked managed radio, if any. */
|
|
595
|
+
get #selectedRadio() {
|
|
596
|
+
return this.#managedTargets.find((radio) => this.#isChecked(radio));
|
|
597
|
+
}
|
|
598
|
+
/** Whether a radio is currently checked. */
|
|
599
|
+
#isChecked(radio) {
|
|
600
|
+
return radio.getAttribute("aria-checked") === "true";
|
|
601
|
+
}
|
|
602
|
+
/** Reflects checked state while distinguishing controller writes from authored morphs. */
|
|
603
|
+
#setChecked(radio, checked) {
|
|
604
|
+
const value = checked ? "true" : "false";
|
|
605
|
+
if (radio.getAttribute("aria-checked") === value) return;
|
|
606
|
+
this.#markInternal(this.#internalCheckedValues, radio, value);
|
|
607
|
+
radio.setAttribute("aria-checked", value);
|
|
608
|
+
}
|
|
609
|
+
/** Writes a roving value before the radio is visible to the shared primitive. */
|
|
610
|
+
#writeTabindex(radio, value) {
|
|
611
|
+
const serialized = String(value);
|
|
612
|
+
this.#markInternal(this.#internalTabindexValues, radio, serialized);
|
|
613
|
+
radio.tabIndex = value;
|
|
614
|
+
}
|
|
615
|
+
/** Mirrors the selected value or the empty state to the optional hidden field. */
|
|
132
616
|
#reflectField(radio, { silent = false } = {}) {
|
|
133
|
-
if (!this.hasFieldTarget
|
|
134
|
-
const value = this.#radioValue(radio);
|
|
617
|
+
if (!this.hasFieldTarget) return;
|
|
618
|
+
const value = radio ? this.#radioValue(radio) : "";
|
|
135
619
|
if (this.fieldTarget.value === value) return;
|
|
136
620
|
this.fieldTarget.value = value;
|
|
137
621
|
if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
|