stimeo-ui 0.16.0 → 0.17.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 +31 -0
- data/README.md +8 -1
- data/dist/cable/index.js +363 -436
- data/dist/controllers/accordion_controller.js +166 -55
- data/dist/controllers/alert_dialog_controller.js +862 -221
- data/dist/controllers/announcer_controller.js +135 -198
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +271 -127
- data/dist/controllers/avatar_controller.js +171 -105
- data/dist/controllers/breadcrumb_controller.js +0 -130
- data/dist/controllers/bulk_select_controller.js +262 -52
- data/dist/controllers/calendar_controller.js +175 -209
- data/dist/controllers/carousel_controller.js +461 -399
- data/dist/controllers/character_counter_controller.js +246 -156
- data/dist/controllers/checkbox_controller.js +32 -74
- data/dist/controllers/clipboard_controller.js +168 -147
- data/dist/controllers/collapsible_controller.js +301 -120
- data/dist/controllers/color_picker_controller.js +149 -139
- data/dist/controllers/combobox_controller.js +360 -133
- data/dist/controllers/command_palette_controller.js +970 -358
- data/dist/controllers/conditional_fields_controller.js +166 -124
- data/dist/controllers/confirm_controller.js +939 -248
- data/dist/controllers/context_menu_controller.js +256 -128
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +183 -148
- data/dist/controllers/currency_input_controller.js +134 -158
- data/dist/controllers/data_grid_controller.js +114 -186
- data/dist/controllers/date_range_picker_controller.js +288 -206
- data/dist/controllers/dialog_controller.js +858 -223
- data/dist/controllers/direct_upload_controller.js +73 -153
- data/dist/controllers/dirty_form_controller.js +14 -48
- data/dist/controllers/dismissible_controller.js +175 -21
- data/dist/controllers/drawer_controller.js +933 -341
- data/dist/controllers/dropdown_controller.js +312 -82
- data/dist/controllers/editable_controller.js +158 -68
- data/dist/controllers/empty_state_controller.js +67 -53
- data/dist/controllers/file_dropzone_controller.js +311 -207
- data/dist/controllers/filter_controller.js +67 -39
- data/dist/controllers/flash_controller.js +172 -314
- data/dist/controllers/focus_controller.js +654 -240
- data/dist/controllers/form_field_controller.js +147 -132
- data/dist/controllers/form_validation_controller.js +13 -86
- data/dist/controllers/frame_loading_controller.js +264 -213
- data/dist/controllers/highlight_controller.js +107 -75
- data/dist/controllers/hover_card_controller.js +309 -118
- data/dist/controllers/idle_controller.js +267 -101
- data/dist/controllers/input_mask_controller.js +71 -72
- data/dist/controllers/intersection_controller.js +147 -106
- data/dist/controllers/lazy_frame_controller.js +9 -65
- data/dist/controllers/listbox_controller.js +306 -204
- data/dist/controllers/local_time_controller.js +54 -64
- data/dist/controllers/masonry_controller.js +70 -93
- data/dist/controllers/menu_controller.js +214 -156
- data/dist/controllers/menubar_controller.js +170 -329
- data/dist/controllers/meter_controller.js +151 -67
- data/dist/controllers/multi_select_controller.js +593 -358
- data/dist/controllers/navigation_menu_controller.js +124 -232
- data/dist/controllers/nested_form_controller.js +352 -182
- data/dist/controllers/network_status_controller.js +242 -54
- data/dist/controllers/number_input_controller.js +320 -263
- data/dist/controllers/optimistic_controller.js +16 -76
- data/dist/controllers/otp_controller.js +198 -193
- data/dist/controllers/overflow_indicator_controller.js +116 -113
- data/dist/controllers/overflow_menu_controller.js +343 -283
- data/dist/controllers/pagination_controller.js +295 -119
- data/dist/controllers/password_reveal_controller.js +389 -121
- data/dist/controllers/password_strength_controller.js +99 -153
- data/dist/controllers/persist_controller.js +237 -103
- data/dist/controllers/pointer_drag_controller.js +185 -165
- data/dist/controllers/popover_controller.js +236 -97
- data/dist/controllers/portal_controller.js +209 -76
- data/dist/controllers/preview_guard_controller.js +232 -116
- data/dist/controllers/progress_controller.js +125 -56
- data/dist/controllers/radio_group_controller.js +117 -137
- data/dist/controllers/range_slider_controller.js +192 -105
- data/dist/controllers/rating_controller.js +418 -207
- data/dist/controllers/read_more_controller.js +134 -63
- data/dist/controllers/reading_progress_controller.js +147 -135
- data/dist/controllers/relative_time_controller.js +145 -110
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- data/dist/controllers/resizable_controller.js +255 -139
- data/dist/controllers/roving_controller.js +39 -84
- data/dist/controllers/scroll_area_controller.js +428 -225
- data/dist/controllers/scroll_restore_controller.js +0 -53
- data/dist/controllers/scroll_visibility_controller.js +395 -145
- data/dist/controllers/scrollspy_controller.js +181 -242
- data/dist/controllers/separator_controller.js +267 -140
- data/dist/controllers/sidebar_controller.js +1023 -386
- data/dist/controllers/skeleton_controller.js +248 -97
- data/dist/controllers/slider_controller.js +159 -79
- data/dist/controllers/smart_sticky_header_controller.js +162 -63
- data/dist/controllers/sortable_controller.js +0 -112
- data/dist/controllers/spinner_controller.js +277 -169
- data/dist/controllers/step_indicator_controller.js +99 -46
- data/dist/controllers/stepper_controller.js +114 -73
- data/dist/controllers/stick_to_bottom_controller.js +262 -138
- data/dist/controllers/sticky_observer_controller.js +214 -49
- data/dist/controllers/submit_once_controller.js +294 -205
- data/dist/controllers/switch_controller.js +33 -60
- data/dist/controllers/tabs_controller.js +35 -29
- data/dist/controllers/tags_input_controller.js +136 -145
- data/dist/controllers/textarea_autosize_controller.js +71 -58
- data/dist/controllers/theme_controller.js +130 -112
- data/dist/controllers/time_picker_controller.js +146 -80
- data/dist/controllers/toast_controller.js +186 -297
- data/dist/controllers/toggle_group_controller.js +95 -136
- data/dist/controllers/toolbar_controller.js +68 -98
- data/dist/controllers/tooltip_controller.js +158 -124
- data/dist/controllers/transition_controller.js +123 -119
- data/dist/controllers/tree_view_controller.js +150 -345
- data/dist/index.js +6370 -9346
- data/dist/positioning/index.js +148 -47
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
|
|
4
|
+
var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
|
|
5
|
+
function stateReasonFor(event) {
|
|
6
|
+
if (!event) return "api";
|
|
7
|
+
if (FOCUS_EVENTS.has(event.type)) return "focus";
|
|
8
|
+
if (POINTER_EVENTS.has(event.type)) return "pointer";
|
|
9
|
+
return "user";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function actionSource(source) {
|
|
13
|
+
if (source instanceof HTMLElement) {
|
|
14
|
+
return { event: null, host: source, origin: source, reason: "api" };
|
|
15
|
+
}
|
|
16
|
+
const event = source ?? null;
|
|
17
|
+
return {
|
|
18
|
+
event,
|
|
19
|
+
host: event?.currentTarget instanceof Element ? event.currentTarget : null,
|
|
20
|
+
origin: event?.target instanceof Element ? event.target : null,
|
|
21
|
+
reason: stateReasonFor(event)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
4
24
|
|
|
5
|
-
// src/utils/logical_scroll.ts
|
|
6
25
|
function isRtl(element) {
|
|
7
26
|
return window.getComputedStyle(element).direction === "rtl";
|
|
8
27
|
}
|
|
9
28
|
|
|
10
|
-
// src/utils/arrow_step.ts
|
|
11
29
|
function logicalArrowStep(key, element) {
|
|
12
30
|
if (key === "ArrowDown") return 1;
|
|
13
31
|
if (key === "ArrowUp") return -1;
|
|
@@ -20,7 +38,6 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
20
38
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
21
39
|
}
|
|
22
40
|
|
|
23
|
-
// src/utils/event_owner.ts
|
|
24
41
|
function ownerIndex(candidates, node) {
|
|
25
42
|
if (!(node instanceof Node)) return -1;
|
|
26
43
|
return candidates.findIndex((candidate) => candidate.contains(node));
|
|
@@ -29,7 +46,6 @@ function ownerOf(candidates, node) {
|
|
|
29
46
|
return candidates[ownerIndex(candidates, node)] ?? null;
|
|
30
47
|
}
|
|
31
48
|
|
|
32
|
-
// src/utils/field_mirror.ts
|
|
33
49
|
function writeField(field, value) {
|
|
34
50
|
if (field.value === value) return false;
|
|
35
51
|
field.value = value;
|
|
@@ -39,7 +55,6 @@ function commitField(target) {
|
|
|
39
55
|
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
// src/utils/focus_candidate.ts
|
|
43
58
|
function inheritsFieldsetDisabled(control) {
|
|
44
59
|
let fieldset = control.closest("fieldset[disabled]");
|
|
45
60
|
while (fieldset) {
|
|
@@ -50,7 +65,6 @@ function inheritsFieldsetDisabled(control) {
|
|
|
50
65
|
return false;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
// src/utils/interactive_host.ts
|
|
54
68
|
var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
|
|
55
69
|
function isInteractiveHost(element) {
|
|
56
70
|
if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
|
|
@@ -67,27 +81,33 @@ function isInteractiveHost(element) {
|
|
|
67
81
|
return false;
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
|
|
84
|
+
var ListenerSet = class {
|
|
85
|
+
#abort = new AbortController();
|
|
86
|
+
add(target, type, handler, options) {
|
|
87
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
88
|
+
}
|
|
89
|
+
dispose() {
|
|
90
|
+
this.#abort.abort();
|
|
91
|
+
this.#abort = new AbortController();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
71
95
|
var MicrotaskCoalescer = class {
|
|
72
96
|
#run;
|
|
73
97
|
#queued = false;
|
|
74
98
|
#active = false;
|
|
75
99
|
#generation = 0;
|
|
76
|
-
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
77
100
|
constructor(run) {
|
|
78
101
|
this.#run = run;
|
|
79
102
|
}
|
|
80
|
-
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
81
103
|
activate() {
|
|
82
104
|
this.#active = true;
|
|
83
105
|
}
|
|
84
|
-
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
85
106
|
cancel() {
|
|
86
107
|
this.#active = false;
|
|
87
108
|
this.#queued = false;
|
|
88
109
|
this.#generation += 1;
|
|
89
110
|
}
|
|
90
|
-
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
91
111
|
schedule() {
|
|
92
112
|
if (!this.#active || this.#queued) return;
|
|
93
113
|
this.#queued = true;
|
|
@@ -100,31 +120,50 @@ var MicrotaskCoalescer = class {
|
|
|
100
120
|
}
|
|
101
121
|
};
|
|
102
122
|
|
|
103
|
-
|
|
123
|
+
var MorphRenderWatcher = class {
|
|
124
|
+
#listeners = new ListenerSet();
|
|
125
|
+
#pass;
|
|
126
|
+
#element = null;
|
|
127
|
+
#onMorph = () => this.#pass.schedule();
|
|
128
|
+
constructor(run) {
|
|
129
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
130
|
+
}
|
|
131
|
+
observe(element) {
|
|
132
|
+
if (this.#element === element) return;
|
|
133
|
+
this.disconnect();
|
|
134
|
+
this.#element = element;
|
|
135
|
+
this.#pass.activate();
|
|
136
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
137
|
+
}
|
|
138
|
+
schedule() {
|
|
139
|
+
this.#pass.schedule();
|
|
140
|
+
}
|
|
141
|
+
disconnect() {
|
|
142
|
+
this.#listeners.dispose();
|
|
143
|
+
this.#pass.cancel();
|
|
144
|
+
this.#element = null;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
var MoveCounter = class {
|
|
149
|
+
#sequence = 0;
|
|
150
|
+
record() {
|
|
151
|
+
this.#sequence += 1;
|
|
152
|
+
return this.#sequence;
|
|
153
|
+
}
|
|
154
|
+
isLatest(token) {
|
|
155
|
+
return token === this.#sequence;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
104
159
|
var RovingTabindex = class {
|
|
105
|
-
/** Returns the current ordered item elements; called on every operation. */
|
|
106
160
|
#getItems;
|
|
107
|
-
/**
|
|
108
|
-
* @param getItems - Returns the current ordered item elements. Called on every
|
|
109
|
-
* operation so the live target list is always used.
|
|
110
|
-
*/
|
|
111
161
|
constructor(getItems) {
|
|
112
162
|
this.#getItems = getItems;
|
|
113
163
|
}
|
|
114
|
-
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
115
164
|
get activeIndex() {
|
|
116
165
|
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
117
166
|
}
|
|
118
|
-
/**
|
|
119
|
-
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
120
|
-
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
121
|
-
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
122
|
-
* "nothing is currently tabbable".
|
|
123
|
-
*
|
|
124
|
-
* @param index - Position of the item to make tabbable.
|
|
125
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
126
|
-
* and `items` to reuse an event-scoped collection snapshot.
|
|
127
|
-
*/
|
|
128
167
|
setActive(index, options = {}) {
|
|
129
168
|
const { focus = false } = options;
|
|
130
169
|
const items = options.items ?? this.#getItems();
|
|
@@ -140,7 +179,10 @@ function rovingMove(current, length, delta, wrap) {
|
|
|
140
179
|
return (next + length) % length;
|
|
141
180
|
}
|
|
142
181
|
|
|
143
|
-
|
|
182
|
+
function targetSelector(identifier, name) {
|
|
183
|
+
return `[data-${identifier}-target~="${name}"]`;
|
|
184
|
+
}
|
|
185
|
+
|
|
144
186
|
var NO_SELECTION = { radio: null, value: "" };
|
|
145
187
|
var OBSERVED_ATTRIBUTES = [
|
|
146
188
|
"aria-checked",
|
|
@@ -157,11 +199,13 @@ var OBSERVED_ATTRIBUTES = [
|
|
|
157
199
|
];
|
|
158
200
|
var hasModifier = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
159
201
|
var RadioGroupController = class extends Controller {
|
|
202
|
+
#moves = new MoveCounter();
|
|
203
|
+
#moveToken = 0;
|
|
160
204
|
static targets = ["radio", "field"];
|
|
161
205
|
static actions = ["onKeydown", "select"];
|
|
162
206
|
static events = ["change", "reconcile"];
|
|
163
207
|
#roving = new RovingTabindex(() => this.#managedTargets);
|
|
164
|
-
#reconcile = new
|
|
208
|
+
#reconcile = new MorphRenderWatcher(() => this.#reconcileDom());
|
|
165
209
|
#handledEvents = /* @__PURE__ */ new WeakSet();
|
|
166
210
|
#managedRadios = /* @__PURE__ */ new Set();
|
|
167
211
|
#originalTabindex = /* @__PURE__ */ new Map();
|
|
@@ -169,17 +213,12 @@ var RadioGroupController = class extends Controller {
|
|
|
169
213
|
#internalCheckedValues = /* @__PURE__ */ new Map();
|
|
170
214
|
#internalTabindexValues = /* @__PURE__ */ new Map();
|
|
171
215
|
#observer = null;
|
|
172
|
-
/** The selection last published: read on connect, then committed or reported. */
|
|
173
216
|
#committed = NO_SELECTION;
|
|
174
217
|
#connected = false;
|
|
175
218
|
#lastOrder = [];
|
|
176
219
|
#focusedRadio = null;
|
|
177
220
|
#pendingFocusIndex = null;
|
|
178
221
|
#preferChecked = false;
|
|
179
|
-
/**
|
|
180
|
-
* Normalizes authored selection, establishes the APG Tab entry point, and
|
|
181
|
-
* reflects derived form state without reporting a user edit.
|
|
182
|
-
*/
|
|
183
222
|
connect() {
|
|
184
223
|
const currentRadios = new Set(this.radioTargets);
|
|
185
224
|
for (const radio of this.#managedRadios) {
|
|
@@ -188,7 +227,7 @@ var RadioGroupController = class extends Controller {
|
|
|
188
227
|
for (const radio of this.radioTargets) this.#reconcileHost(radio, false);
|
|
189
228
|
this.#normalizeSelection();
|
|
190
229
|
this.#ensureTabStop(true);
|
|
191
|
-
this.#reflectField(this.#selectedRadio
|
|
230
|
+
this.#reflectField(this.#selectedRadio);
|
|
192
231
|
this.#committed = this.#settled;
|
|
193
232
|
this.#lastOrder = this.#managedTargets;
|
|
194
233
|
this.element.addEventListener("click", this.#onClickCapture, true);
|
|
@@ -201,13 +240,12 @@ var RadioGroupController = class extends Controller {
|
|
|
201
240
|
this.#preferChecked = false;
|
|
202
241
|
this.#internalCheckedValues.clear();
|
|
203
242
|
this.#internalTabindexValues.clear();
|
|
204
|
-
this.#reconcile.
|
|
243
|
+
this.#reconcile.observe(this.element);
|
|
205
244
|
this.#observeMutations();
|
|
206
245
|
}
|
|
207
|
-
/** Releases every listener, observer, and queued pass while retaining live DOM state. */
|
|
208
246
|
disconnect() {
|
|
209
247
|
this.#connected = false;
|
|
210
|
-
this.#reconcile.
|
|
248
|
+
this.#reconcile.disconnect();
|
|
211
249
|
this.element.removeEventListener("click", this.#onClickCapture, true);
|
|
212
250
|
this.element.removeEventListener("keydown", this.#onKeydownCapture, true);
|
|
213
251
|
this.element.removeEventListener("click", this.#onClick);
|
|
@@ -216,21 +254,17 @@ var RadioGroupController = class extends Controller {
|
|
|
216
254
|
this.element.removeEventListener("focusout", this.#onFocusout);
|
|
217
255
|
this.#observer?.disconnect();
|
|
218
256
|
this.#observer = null;
|
|
219
|
-
this.#internalCheckedValues.clear();
|
|
220
|
-
this.#internalTabindexValues.clear();
|
|
221
257
|
this.#focusedRadio = null;
|
|
222
258
|
this.#pendingFocusIndex = null;
|
|
223
259
|
this.#committed = NO_SELECTION;
|
|
224
260
|
this.#preferChecked = false;
|
|
225
261
|
}
|
|
226
|
-
/** Removes a newly connected radio from the Tab sequence before batch reconciliation. */
|
|
227
262
|
radioTargetConnected(radio) {
|
|
228
263
|
if (this.#connected === false) return;
|
|
229
264
|
this.#reconcileHost(radio, true);
|
|
230
265
|
this.#preferChecked = true;
|
|
231
266
|
this.#reconcile.schedule();
|
|
232
267
|
}
|
|
233
|
-
/** Releases target-only attributes and repairs selection, form state, focus, and roving. */
|
|
234
268
|
radioTargetDisconnected(radio) {
|
|
235
269
|
if (!this.#connected) return;
|
|
236
270
|
if (this.#focusedRadio === radio) {
|
|
@@ -241,36 +275,33 @@ var RadioGroupController = class extends Controller {
|
|
|
241
275
|
this.#preferChecked = true;
|
|
242
276
|
this.#reconcile.schedule();
|
|
243
277
|
}
|
|
244
|
-
/** Reflects the current selection into a field added or replaced at runtime. */
|
|
245
278
|
fieldTargetConnected() {
|
|
246
279
|
this.#reconcile.schedule();
|
|
247
280
|
}
|
|
248
|
-
/** Reconciles after a field target is removed from a retained group. */
|
|
249
281
|
fieldTargetDisconnected() {
|
|
250
282
|
this.#reconcile.schedule();
|
|
251
283
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const radio = event.currentTarget;
|
|
284
|
+
select(source) {
|
|
285
|
+
const { event, host, origin, reason } = actionSource(source);
|
|
286
|
+
const radio = host?.closest(targetSelector(this.identifier, "radio"));
|
|
256
287
|
if (!radio || !this.radioTargets.includes(radio)) return;
|
|
257
|
-
this
|
|
258
|
-
|
|
288
|
+
if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
|
|
289
|
+
return;
|
|
290
|
+
if (event?.defaultPrevented || event && !this.#ownsEventTarget(origin)) return;
|
|
291
|
+
if (event) this.#handledEvents.add(event);
|
|
292
|
+
this.#selectRadio(radio, { focus: false }, reason);
|
|
259
293
|
}
|
|
260
|
-
/** Applies the APG key map to the action's radio. Per-radio action wiring is optional. */
|
|
261
294
|
onKeydown(event) {
|
|
262
295
|
const radio = event.currentTarget;
|
|
263
296
|
if (!radio || !this.radioTargets.includes(radio)) return;
|
|
264
297
|
this.#handleKeydown(event, radio);
|
|
265
298
|
}
|
|
266
|
-
/** Blocks disabled pointer activation before per-radio and consumer handlers run. */
|
|
267
299
|
#onClickCapture = (event) => {
|
|
268
300
|
const radio = this.#radioForEventTarget(event.target);
|
|
269
301
|
if (!radio || !this.#isSupportedHost(radio) || !this.#isActivationDisabled(radio)) return;
|
|
270
302
|
event.preventDefault();
|
|
271
303
|
event.stopImmediatePropagation();
|
|
272
304
|
};
|
|
273
|
-
/** Blocks disabled activation and suppresses a button's non-APG Enter activation. */
|
|
274
305
|
#onKeydownCapture = (event) => {
|
|
275
306
|
if (event.key !== " " && event.key !== "Enter") return;
|
|
276
307
|
const radio = this.#radioForEventTarget(event.target);
|
|
@@ -280,25 +311,21 @@ var RadioGroupController = class extends Controller {
|
|
|
280
311
|
event.stopImmediatePropagation();
|
|
281
312
|
}
|
|
282
313
|
};
|
|
283
|
-
/** Delegated click path for static and runtime-added radios without actions. */
|
|
284
314
|
#onClick = (event) => {
|
|
285
315
|
if (this.#handledEvents.delete(event) || event.defaultPrevented) return;
|
|
286
316
|
const radio = this.#radioForEventTarget(event.target);
|
|
287
317
|
if (radio) this.#selectRadio(radio, { focus: false });
|
|
288
318
|
};
|
|
289
|
-
/** Delegated keydown path for static and runtime-added radios without actions. */
|
|
290
319
|
#onKeydown = (event) => {
|
|
291
320
|
const radio = this.#radioForEventTarget(event.target);
|
|
292
321
|
if (radio) this.#handleKeydown(event, radio);
|
|
293
322
|
};
|
|
294
|
-
/** Keeps programmatic and pointer focus as the group's current roving position. */
|
|
295
323
|
#onFocusin = (event) => {
|
|
296
324
|
const radio = this.#radioForEventTarget(event.target);
|
|
297
325
|
if (!radio || !this.#isNavigable(radio)) return;
|
|
298
326
|
this.#focusedRadio = radio;
|
|
299
327
|
this.#setActive(radio);
|
|
300
328
|
};
|
|
301
|
-
/** Distinguishes ordinary focus departure from focus lost because its radio was removed. */
|
|
302
329
|
#onFocusout = (event) => {
|
|
303
330
|
const radio = this.#radioForEventTarget(event.target);
|
|
304
331
|
if (!radio) return;
|
|
@@ -308,7 +335,6 @@ var RadioGroupController = class extends Controller {
|
|
|
308
335
|
if (!(active instanceof Node) || !radio.contains(active)) this.#focusedRadio = null;
|
|
309
336
|
});
|
|
310
337
|
};
|
|
311
|
-
/** Applies navigation and Space activation to one supported radio. */
|
|
312
338
|
#handleKeydown(event, radio) {
|
|
313
339
|
if (event.defaultPrevented || !this.#isSupportedHost(radio)) return;
|
|
314
340
|
if (event.isComposing || isReservedArrowChord(event)) return;
|
|
@@ -346,65 +372,54 @@ var RadioGroupController = class extends Controller {
|
|
|
346
372
|
if (this.#isActivationDisabled(destination)) this.#setActive(destination, true);
|
|
347
373
|
else this.#selectRadio(destination, { focus: true });
|
|
348
374
|
}
|
|
349
|
-
|
|
350
|
-
* Applies one user selection and emits only when selected identity changes. A
|
|
351
|
-
* new selection is taken as published before either report goes out, so a
|
|
352
|
-
* listener that selects again is measured from it. Activating the radio that is
|
|
353
|
-
* already checked publishes nothing: if the page checked it, the next pass
|
|
354
|
-
* reports that move.
|
|
355
|
-
*/
|
|
356
|
-
#selectRadio(radio, { focus }) {
|
|
375
|
+
#selectRadio(radio, { focus }, reason = "user") {
|
|
357
376
|
if (!this.#isSupportedHost(radio) || this.#isActivationDisabled(radio)) return;
|
|
358
|
-
const
|
|
359
|
-
const changed =
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
this.#setActive(radio
|
|
365
|
-
this
|
|
366
|
-
if (
|
|
367
|
-
|
|
377
|
+
const value = this.#radioValue(radio);
|
|
378
|
+
const changed = this.#committed.radio !== radio || this.#committed.value !== value;
|
|
379
|
+
for (const item of this.#managedTargets) this.#setChecked(item, item === radio);
|
|
380
|
+
this.#committed = { radio, value };
|
|
381
|
+
if (changed) this.#moveToken = this.#moves.record();
|
|
382
|
+
const token = this.#moveToken;
|
|
383
|
+
this.#setActive(radio);
|
|
384
|
+
const fieldChanged = this.hasFieldTarget && writeField(this.fieldTarget, value);
|
|
385
|
+
if (focus) radio.focus();
|
|
386
|
+
if (!this.#connected || !this.#moves.isLatest(token)) return;
|
|
387
|
+
if (changed && fieldChanged) commitField(this.fieldTarget);
|
|
388
|
+
if (changed && this.#moves.isLatest(token)) {
|
|
389
|
+
this.dispatch("change", { detail: { value, radio, reason } });
|
|
368
390
|
}
|
|
369
391
|
}
|
|
370
|
-
/** Reconciles target ownership, selection, roving, focus continuity, and form state. */
|
|
371
392
|
#reconcileDom() {
|
|
372
393
|
const observer = this.#observer;
|
|
373
|
-
observer?.
|
|
394
|
+
this.#handleMutationRecords(observer?.takeRecords() ?? []);
|
|
374
395
|
for (const radio of this.radioTargets) this.#reconcileHost(radio, true);
|
|
375
396
|
this.#normalizeSelection();
|
|
376
397
|
const pendingFocusIndex = this.#pendingFocusIndex;
|
|
377
398
|
this.#pendingFocusIndex = null;
|
|
399
|
+
let destination = null;
|
|
378
400
|
if (pendingFocusIndex !== null) {
|
|
379
|
-
|
|
380
|
-
this.#setActive(destination
|
|
401
|
+
destination = this.#nearestNavigable(pendingFocusIndex);
|
|
402
|
+
this.#setActive(destination);
|
|
381
403
|
} else {
|
|
382
404
|
this.#ensureTabStop(this.#preferChecked);
|
|
383
405
|
}
|
|
384
|
-
this.#reflectField(this.#selectedRadio
|
|
406
|
+
this.#reflectField(this.#selectedRadio);
|
|
385
407
|
this.#lastOrder = this.#managedTargets;
|
|
386
408
|
this.#preferChecked = false;
|
|
409
|
+
observer?.takeRecords();
|
|
387
410
|
this.#internalCheckedValues.clear();
|
|
388
411
|
this.#internalTabindexValues.clear();
|
|
389
|
-
if (this.#connected && observer) this.#observeWith(observer);
|
|
390
|
-
this.#reportReconciledSelection();
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* Announces a selection that moved since the one this group last published —
|
|
394
|
-
* another radio, or the same radio submitting another value. Dispatched after
|
|
395
|
-
* observation resumes so a consumer's own DOM edits are seen by the next pass,
|
|
396
|
-
* and only while connected: moving focus inside the pass runs listeners that
|
|
397
|
-
* can disconnect the group.
|
|
398
|
-
*/
|
|
399
|
-
#reportReconciledSelection() {
|
|
400
|
-
if (!this.#connected) return;
|
|
401
412
|
const settled = this.#settled;
|
|
402
413
|
const committed = this.#committed;
|
|
403
|
-
|
|
414
|
+
const changed = settled.radio !== committed.radio || settled.value !== committed.value;
|
|
404
415
|
this.#committed = settled;
|
|
405
|
-
|
|
416
|
+
const token = this.#moveToken;
|
|
417
|
+
destination?.focus();
|
|
418
|
+
if (!this.#connected || !this.#moves.isLatest(token)) return;
|
|
419
|
+
if (changed) {
|
|
420
|
+
this.dispatch("reconcile", { detail: { value: settled.value, radio: settled.radio } });
|
|
421
|
+
}
|
|
406
422
|
}
|
|
407
|
-
/** Begins or ends ownership according to a radio's current host semantics. */
|
|
408
423
|
#reconcileHost(radio, dropFromTabSequence) {
|
|
409
424
|
if (!this.#isSupportedHost(radio)) {
|
|
410
425
|
this.#releaseRadio(radio);
|
|
@@ -417,7 +432,6 @@ var RadioGroupController = class extends Controller {
|
|
|
417
432
|
}
|
|
418
433
|
this.#normalizeChecked(radio);
|
|
419
434
|
}
|
|
420
|
-
/** Restores only defaults owned while an element belonged to this group. */
|
|
421
435
|
#releaseRadio(radio) {
|
|
422
436
|
this.#managedRadios.delete(radio);
|
|
423
437
|
const original = this.#originalTabindex.get(radio);
|
|
@@ -434,7 +448,6 @@ var RadioGroupController = class extends Controller {
|
|
|
434
448
|
radio.removeAttribute("aria-checked");
|
|
435
449
|
}
|
|
436
450
|
}
|
|
437
|
-
/** Supplies a missing checked state and normalizes invalid ARIA tokens. */
|
|
438
451
|
#normalizeChecked(radio) {
|
|
439
452
|
const value = radio.getAttribute("aria-checked");
|
|
440
453
|
if (value === null) {
|
|
@@ -444,17 +457,14 @@ var RadioGroupController = class extends Controller {
|
|
|
444
457
|
this.#setChecked(radio, false);
|
|
445
458
|
}
|
|
446
459
|
}
|
|
447
|
-
/** Makes the first DOM-ordered checked radio the sole checked radio. */
|
|
448
460
|
#normalizeSelection() {
|
|
449
461
|
let found = false;
|
|
450
462
|
for (const radio of this.#managedTargets) {
|
|
451
|
-
this.#normalizeChecked(radio);
|
|
452
463
|
if (!this.#isChecked(radio)) continue;
|
|
453
464
|
if (!found) found = true;
|
|
454
465
|
else this.#setChecked(radio, false);
|
|
455
466
|
}
|
|
456
467
|
}
|
|
457
|
-
/** Keeps one navigable Tab stop, preferring checked state when selection changed externally. */
|
|
458
468
|
#ensureTabStop(preferChecked) {
|
|
459
469
|
const navigable = this.#navigableRadios;
|
|
460
470
|
let active;
|
|
@@ -469,12 +479,6 @@ var RadioGroupController = class extends Controller {
|
|
|
469
479
|
active ??= navigable[0];
|
|
470
480
|
this.#setActive(active ?? null);
|
|
471
481
|
}
|
|
472
|
-
/**
|
|
473
|
-
* The surviving navigable radio closest to a position in the order captured
|
|
474
|
-
* before the removal: the first one at or after it, else the last one before it.
|
|
475
|
-
* The saved position and this search read the same population, so a `hidden` or
|
|
476
|
-
* natively disabled sibling never shifts the destination past its neighbor.
|
|
477
|
-
*/
|
|
478
482
|
#nearestNavigable(index) {
|
|
479
483
|
const navigable = new Set(this.#navigableRadios);
|
|
480
484
|
for (let i = index; i < this.#lastOrder.length; i++) {
|
|
@@ -487,7 +491,6 @@ var RadioGroupController = class extends Controller {
|
|
|
487
491
|
}
|
|
488
492
|
return this.#navigableRadios[0] ?? null;
|
|
489
493
|
}
|
|
490
|
-
/** Moves through full DOM order until a navigable radio is found. */
|
|
491
494
|
#nextNavigable(fromIndex, delta) {
|
|
492
495
|
const items = this.#managedTargets;
|
|
493
496
|
let index = fromIndex;
|
|
@@ -498,36 +501,31 @@ var RadioGroupController = class extends Controller {
|
|
|
498
501
|
}
|
|
499
502
|
return void 0;
|
|
500
503
|
}
|
|
501
|
-
/** Assigns the single roving Tab stop and optionally moves DOM focus. */
|
|
502
504
|
#setActive(radio, focus = false) {
|
|
503
505
|
const items = this.#managedTargets;
|
|
504
506
|
const index = items.indexOf(radio);
|
|
505
|
-
this.#roving.setActive(index
|
|
507
|
+
this.#roving.setActive(index);
|
|
506
508
|
for (const item of items) {
|
|
507
509
|
this.#markInternal(this.#internalTabindexValues, item, item.getAttribute("tabindex"));
|
|
508
510
|
}
|
|
511
|
+
if (focus) radio?.focus();
|
|
509
512
|
}
|
|
510
|
-
/** Finds this group's radio containing an event target, excluding nested groups. */
|
|
511
513
|
#radioForEventTarget(target) {
|
|
512
514
|
if (!this.#ownsEventTarget(target)) return null;
|
|
513
515
|
return ownerOf(this.radioTargets, target);
|
|
514
516
|
}
|
|
515
|
-
/** Whether the closest Radio Group scope around a target is this instance. */
|
|
516
517
|
#ownsEventTarget(target) {
|
|
517
518
|
return target instanceof Element && target.closest(`[data-controller~="${this.identifier}"]`) === this.element;
|
|
518
519
|
}
|
|
519
|
-
/** Hosts whose activation model can be owned without conflicting native behavior. */
|
|
520
520
|
#isSupportedHost(radio) {
|
|
521
521
|
if (radio instanceof HTMLButtonElement) return radio.type === "button";
|
|
522
522
|
return !isInteractiveHost(radio);
|
|
523
523
|
}
|
|
524
|
-
/** Radios eligible for roving focus; ARIA-disabled deliberately remains eligible. */
|
|
525
524
|
#isNavigable(radio) {
|
|
526
525
|
if (!this.#isSupportedHost(radio) || this.#isHidden(radio)) return false;
|
|
527
526
|
if (!(radio instanceof HTMLButtonElement)) return true;
|
|
528
527
|
return !radio.disabled && !inheritsFieldsetDisabled(radio);
|
|
529
528
|
}
|
|
530
|
-
/** Whether hidden applies on the path from a radio up to the group root. */
|
|
531
529
|
#isHidden(radio) {
|
|
532
530
|
let current = radio;
|
|
533
531
|
while (current && current !== this.element) {
|
|
@@ -536,7 +534,6 @@ var RadioGroupController = class extends Controller {
|
|
|
536
534
|
}
|
|
537
535
|
return false;
|
|
538
536
|
}
|
|
539
|
-
/** Whether ARIA, visibility, or native HTML semantics suppress selection. */
|
|
540
537
|
#isActivationDisabled(radio) {
|
|
541
538
|
if (!this.#isNavigable(radio)) return true;
|
|
542
539
|
let current = radio;
|
|
@@ -546,7 +543,6 @@ var RadioGroupController = class extends Controller {
|
|
|
546
543
|
}
|
|
547
544
|
return false;
|
|
548
545
|
}
|
|
549
|
-
/** Distinguishes external retained-element changes from this controller's own writes. */
|
|
550
546
|
#handleMutationRecords(records) {
|
|
551
547
|
let external = false;
|
|
552
548
|
for (const record of records) {
|
|
@@ -567,11 +563,9 @@ var RadioGroupController = class extends Controller {
|
|
|
567
563
|
external = true;
|
|
568
564
|
}
|
|
569
565
|
}
|
|
570
|
-
this.#internalCheckedValues.clear();
|
|
571
566
|
this.#internalTabindexValues.clear();
|
|
572
567
|
if (external) this.#reconcile.schedule();
|
|
573
568
|
}
|
|
574
|
-
/** Records an attribute write this pass made, keeping one claim per record it will produce. */
|
|
575
569
|
#markInternal(values, target, value) {
|
|
576
570
|
const pending = values.get(target);
|
|
577
571
|
if (pending) {
|
|
@@ -581,7 +575,6 @@ var RadioGroupController = class extends Controller {
|
|
|
581
575
|
values.set(target, { value, count: 1 });
|
|
582
576
|
}
|
|
583
577
|
}
|
|
584
|
-
/** Consumes one claim on a record whose value this pass wrote. */
|
|
585
578
|
#matchesInternal(values, target, value) {
|
|
586
579
|
const pending = values.get(target);
|
|
587
580
|
if (!pending || pending.value !== value) return false;
|
|
@@ -589,13 +582,11 @@ var RadioGroupController = class extends Controller {
|
|
|
589
582
|
if (pending.count === 0) values.delete(target);
|
|
590
583
|
return true;
|
|
591
584
|
}
|
|
592
|
-
/** Watches target membership, retained state/hosts, form state, and fieldset ancestry. */
|
|
593
585
|
#observeMutations() {
|
|
594
586
|
const observer = new MutationObserver((records) => this.#handleMutationRecords(records));
|
|
595
587
|
this.#observer = observer;
|
|
596
588
|
this.#observeWith(observer);
|
|
597
589
|
}
|
|
598
|
-
/** Registers root and ancestor observation on one observer instance. */
|
|
599
590
|
#observeWith(observer) {
|
|
600
591
|
observer.observe(this.element, {
|
|
601
592
|
subtree: true,
|
|
@@ -612,53 +603,42 @@ var RadioGroupController = class extends Controller {
|
|
|
612
603
|
ancestor = ancestor.parentElement;
|
|
613
604
|
}
|
|
614
605
|
}
|
|
615
|
-
/** Supported targets currently managed by the roving primitive. */
|
|
616
606
|
get #managedTargets() {
|
|
617
607
|
return this.radioTargets.filter((radio) => this.#managedRadios.has(radio));
|
|
618
608
|
}
|
|
619
|
-
/** Managed radios eligible for the roving Tab stop. */
|
|
620
609
|
get #navigableRadios() {
|
|
621
610
|
return this.#managedTargets.filter((radio) => this.#isNavigable(radio));
|
|
622
611
|
}
|
|
623
|
-
/** The currently checked managed radio, if any. */
|
|
624
612
|
get #selectedRadio() {
|
|
625
613
|
return this.#managedTargets.find((radio) => this.#isChecked(radio));
|
|
626
614
|
}
|
|
627
|
-
/** The current selection as the group publishes it. */
|
|
628
615
|
get #settled() {
|
|
629
616
|
const radio = this.#selectedRadio;
|
|
630
617
|
return radio ? { radio, value: this.#radioValue(radio) } : NO_SELECTION;
|
|
631
618
|
}
|
|
632
|
-
/** Whether a radio is currently checked. */
|
|
633
619
|
#isChecked(radio) {
|
|
634
620
|
return radio.getAttribute("aria-checked") === "true";
|
|
635
621
|
}
|
|
636
|
-
/** Reflects checked state while distinguishing controller writes from authored morphs. */
|
|
637
622
|
#setChecked(radio, checked) {
|
|
638
623
|
const value = checked ? "true" : "false";
|
|
639
624
|
if (radio.getAttribute("aria-checked") === value) return;
|
|
640
625
|
this.#markInternal(this.#internalCheckedValues, radio, value);
|
|
641
626
|
radio.setAttribute("aria-checked", value);
|
|
642
627
|
}
|
|
643
|
-
/** Writes a roving value before the radio is visible to the shared primitive. */
|
|
644
628
|
#writeTabindex(radio, value) {
|
|
645
629
|
const serialized = String(value);
|
|
646
630
|
this.#markInternal(this.#internalTabindexValues, radio, serialized);
|
|
647
631
|
radio.tabIndex = value;
|
|
648
632
|
}
|
|
649
|
-
|
|
650
|
-
#reflectField(radio, { silent = false } = {}) {
|
|
633
|
+
#reflectField(radio) {
|
|
651
634
|
if (!this.hasFieldTarget) return;
|
|
652
635
|
const value = radio ? this.#radioValue(radio) : "";
|
|
653
|
-
|
|
654
|
-
if (!silent) commitField(this.fieldTarget);
|
|
636
|
+
writeField(this.fieldTarget, value);
|
|
655
637
|
}
|
|
656
|
-
/** A radio's submitted value (`data-value`, defaulting to empty). */
|
|
657
638
|
#radioValue(radio) {
|
|
658
639
|
return radio.getAttribute("data-value") ?? "";
|
|
659
640
|
}
|
|
660
641
|
};
|
|
661
642
|
|
|
662
643
|
export { RadioGroupController };
|
|
663
|
-
//# sourceMappingURL=radio_group_controller.js.map
|
|
664
644
|
//# sourceMappingURL=radio_group_controller.js.map
|