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,8 +1,27 @@
|
|
|
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/announce.ts
|
|
6
25
|
function announce(message, options = {}) {
|
|
7
26
|
const text = message.trim();
|
|
8
27
|
if (text.length === 0) return;
|
|
@@ -19,13 +38,22 @@ function fillTemplate(template, values) {
|
|
|
19
38
|
});
|
|
20
39
|
}
|
|
21
40
|
|
|
22
|
-
|
|
23
|
-
|
|
41
|
+
function sharedRegistry(key, create) {
|
|
42
|
+
const symbol = Symbol.for(key);
|
|
43
|
+
const scope = globalThis;
|
|
44
|
+
const existing = scope[symbol];
|
|
45
|
+
if (existing !== void 0) return existing;
|
|
46
|
+
const registry = create();
|
|
47
|
+
Object.defineProperty(globalThis, symbol, { value: registry });
|
|
48
|
+
return registry;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
var ids = sharedRegistry("stimeo-ui.aria-ids.registry.v1", () => ({ counter: 0 }));
|
|
24
52
|
function uniqueId(prefix = "stimeo") {
|
|
25
53
|
let candidate;
|
|
26
54
|
do {
|
|
27
|
-
counter += 1;
|
|
28
|
-
candidate = `${prefix}-${counter}`;
|
|
55
|
+
ids.counter += 1;
|
|
56
|
+
candidate = `${prefix}-${ids.counter}`;
|
|
29
57
|
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
30
58
|
return candidate;
|
|
31
59
|
}
|
|
@@ -36,12 +64,10 @@ function ensureId(element, prefix = "stimeo") {
|
|
|
36
64
|
return id;
|
|
37
65
|
}
|
|
38
66
|
|
|
39
|
-
// src/utils/logical_scroll.ts
|
|
40
67
|
function isRtl(element) {
|
|
41
68
|
return window.getComputedStyle(element).direction === "rtl";
|
|
42
69
|
}
|
|
43
70
|
|
|
44
|
-
// src/utils/arrow_step.ts
|
|
45
71
|
function logicalArrowKey(key, element) {
|
|
46
72
|
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
47
73
|
if (!isRtl(element)) return key;
|
|
@@ -52,7 +78,143 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
52
78
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
53
79
|
}
|
|
54
80
|
|
|
55
|
-
|
|
81
|
+
function isAttributeValue(value) {
|
|
82
|
+
return value === null || typeof value === "string";
|
|
83
|
+
}
|
|
84
|
+
function readLeaseRecord(element, record) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(element.getAttribute(record) ?? "null");
|
|
87
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
88
|
+
const [original, written, ...rest] = parsed;
|
|
89
|
+
const linked = rest[0] !== false;
|
|
90
|
+
const beneath = linked ? rest : rest.slice(1);
|
|
91
|
+
if (!beneath.every((under) => typeof under === "string")) return void 0;
|
|
92
|
+
return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
|
|
93
|
+
} catch {
|
|
94
|
+
return void 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
var AttributeLease = class _AttributeLease {
|
|
98
|
+
static #registry = sharedRegistry(
|
|
99
|
+
"stimeo-ui.attribute-lease.registry.v1",
|
|
100
|
+
() => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
|
|
101
|
+
);
|
|
102
|
+
#attribute;
|
|
103
|
+
#record;
|
|
104
|
+
#base;
|
|
105
|
+
#held = /* @__PURE__ */ new Set();
|
|
106
|
+
constructor(attribute, owner, options = {}) {
|
|
107
|
+
this.#attribute = attribute;
|
|
108
|
+
this.#record = `data-${owner}-${attribute}-lease`;
|
|
109
|
+
this.#base = options.base === true;
|
|
110
|
+
const names = _AttributeLease.#registry.names;
|
|
111
|
+
names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
|
|
112
|
+
}
|
|
113
|
+
write(element, value) {
|
|
114
|
+
const stack = this.#stack(element);
|
|
115
|
+
const hold = this.#take(stack) ?? this.#acquire(element, stack);
|
|
116
|
+
this.#held.add(element);
|
|
117
|
+
const shows = !this.#covered(element, stack, hold);
|
|
118
|
+
hold.written = value;
|
|
119
|
+
this.#mark(element, stack);
|
|
120
|
+
if (shows) this.#reflect(element, value);
|
|
121
|
+
}
|
|
122
|
+
return(element) {
|
|
123
|
+
const held = this.#held.delete(element);
|
|
124
|
+
const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
|
|
125
|
+
const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
|
|
126
|
+
const hold = this.#take(stack);
|
|
127
|
+
if (!hold) return false;
|
|
128
|
+
const carries = element.getAttribute(this.#attribute) === hold.written;
|
|
129
|
+
const owns = carries && !this.#covered(element, stack, hold);
|
|
130
|
+
const index = stack.indexOf(hold);
|
|
131
|
+
stack.splice(index, 1);
|
|
132
|
+
const above = stack[index];
|
|
133
|
+
if (above) above.linked &&= hold.linked;
|
|
134
|
+
this.#mark(element, stack);
|
|
135
|
+
if (!owns) return true;
|
|
136
|
+
const below = hold.linked ? stack[index - 1] : void 0;
|
|
137
|
+
this.#reflect(element, below ? below.written : hold.original);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
returnAll() {
|
|
141
|
+
for (const element of Array.from(this.#held)) this.return(element);
|
|
142
|
+
}
|
|
143
|
+
#stack(element) {
|
|
144
|
+
const stacks = _AttributeLease.#registry.stacks;
|
|
145
|
+
const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
|
|
146
|
+
stacks.set(element, byAttribute);
|
|
147
|
+
const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
|
|
148
|
+
byAttribute.set(this.#attribute, stack);
|
|
149
|
+
return stack;
|
|
150
|
+
}
|
|
151
|
+
#seed(element) {
|
|
152
|
+
const found = [];
|
|
153
|
+
for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
|
|
154
|
+
const read = readLeaseRecord(element, record);
|
|
155
|
+
if (read) found.push({ ...read, record });
|
|
156
|
+
}
|
|
157
|
+
const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
|
|
158
|
+
return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
|
|
159
|
+
lease: null,
|
|
160
|
+
record,
|
|
161
|
+
written,
|
|
162
|
+
original,
|
|
163
|
+
linked: linked && index > 0
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
#take(stack) {
|
|
167
|
+
const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
|
|
168
|
+
if (hold) hold.lease = this;
|
|
169
|
+
return hold;
|
|
170
|
+
}
|
|
171
|
+
#acquire(element, stack) {
|
|
172
|
+
const current = element.getAttribute(this.#attribute);
|
|
173
|
+
const top = stack.at(-1);
|
|
174
|
+
const bottom = stack[0];
|
|
175
|
+
const over = top?.written === current ? top : void 0;
|
|
176
|
+
const from = this.#base ? bottom : over;
|
|
177
|
+
const hold = {
|
|
178
|
+
lease: this,
|
|
179
|
+
record: this.#record,
|
|
180
|
+
written: current,
|
|
181
|
+
original: from ? from.original : current,
|
|
182
|
+
linked: !this.#base && over !== void 0
|
|
183
|
+
};
|
|
184
|
+
if (this.#base && bottom) {
|
|
185
|
+
bottom.linked = true;
|
|
186
|
+
stack.unshift(hold);
|
|
187
|
+
} else stack.push(hold);
|
|
188
|
+
return hold;
|
|
189
|
+
}
|
|
190
|
+
#covered(element, stack, hold) {
|
|
191
|
+
const current = element.getAttribute(this.#attribute);
|
|
192
|
+
return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
|
|
193
|
+
}
|
|
194
|
+
#mark(element, stack) {
|
|
195
|
+
const records = /* @__PURE__ */ new Map([[this.#record, null]]);
|
|
196
|
+
stack.forEach((hold, index) => {
|
|
197
|
+
const names = stack.slice(0, index).map((under) => under.record);
|
|
198
|
+
const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
|
|
199
|
+
const marker = hold.linked || beneath.length === 0 ? [] : [false];
|
|
200
|
+
records.set(
|
|
201
|
+
hold.record,
|
|
202
|
+
hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
for (const [name, recorded] of records) {
|
|
206
|
+
if (element.getAttribute(name) === recorded) continue;
|
|
207
|
+
if (recorded === null) element.removeAttribute(name);
|
|
208
|
+
else element.setAttribute(name, recorded);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
#reflect(element, value) {
|
|
212
|
+
if (element.getAttribute(this.#attribute) === value) return;
|
|
213
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
214
|
+
else element.setAttribute(this.#attribute, value);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
56
218
|
function matchingPart(root, selector) {
|
|
57
219
|
return root.matches(selector) ? root : root.querySelector(selector);
|
|
58
220
|
}
|
|
@@ -79,31 +241,14 @@ function readLabel(slot) {
|
|
|
79
241
|
return text.trim();
|
|
80
242
|
}
|
|
81
243
|
|
|
82
|
-
// src/utils/roving_tabindex.ts
|
|
83
244
|
var RovingTabindex = class {
|
|
84
|
-
/** Returns the current ordered item elements; called on every operation. */
|
|
85
245
|
#getItems;
|
|
86
|
-
/**
|
|
87
|
-
* @param getItems - Returns the current ordered item elements. Called on every
|
|
88
|
-
* operation so the live target list is always used.
|
|
89
|
-
*/
|
|
90
246
|
constructor(getItems) {
|
|
91
247
|
this.#getItems = getItems;
|
|
92
248
|
}
|
|
93
|
-
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
94
249
|
get activeIndex() {
|
|
95
250
|
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
96
251
|
}
|
|
97
|
-
/**
|
|
98
|
-
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
99
|
-
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
100
|
-
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
101
|
-
* "nothing is currently tabbable".
|
|
102
|
-
*
|
|
103
|
-
* @param index - Position of the item to make tabbable.
|
|
104
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
105
|
-
* and `items` to reuse an event-scoped collection snapshot.
|
|
106
|
-
*/
|
|
107
252
|
setActive(index, options = {}) {
|
|
108
253
|
const { focus = false } = options;
|
|
109
254
|
const items = options.items ?? this.#getItems();
|
|
@@ -114,7 +259,6 @@ var RovingTabindex = class {
|
|
|
114
259
|
}
|
|
115
260
|
};
|
|
116
261
|
|
|
117
|
-
// src/utils/chip_row.ts
|
|
118
262
|
var ChipRow = class {
|
|
119
263
|
#directionElement;
|
|
120
264
|
#getItems;
|
|
@@ -130,10 +274,6 @@ var ChipRow = class {
|
|
|
130
274
|
this.#onRemove = options.onRemove;
|
|
131
275
|
this.#focusAfterEnd = options.focusAfterEnd;
|
|
132
276
|
}
|
|
133
|
-
/**
|
|
134
|
-
* Binds delegation to `container`; any row currently bound is released first.
|
|
135
|
-
* Idempotent for the same node, so target callbacks may call it freely.
|
|
136
|
-
*/
|
|
137
277
|
connect(container) {
|
|
138
278
|
if (this.#container === container) return;
|
|
139
279
|
this.disconnect();
|
|
@@ -141,10 +281,6 @@ var ChipRow = class {
|
|
|
141
281
|
container.addEventListener("click", this.#onClick);
|
|
142
282
|
container.addEventListener("keydown", this.#onKeydown);
|
|
143
283
|
}
|
|
144
|
-
/**
|
|
145
|
-
* Releases the current row. When `container` is supplied, a stale disconnect
|
|
146
|
-
* callback cannot tear listeners off a newer replacement target.
|
|
147
|
-
*/
|
|
148
284
|
disconnect(container) {
|
|
149
285
|
const current = this.#container;
|
|
150
286
|
if (!current || container !== void 0 && current !== container) return;
|
|
@@ -152,19 +288,15 @@ var ChipRow = class {
|
|
|
152
288
|
current.removeEventListener("keydown", this.#onKeydown);
|
|
153
289
|
this.#container = null;
|
|
154
290
|
}
|
|
155
|
-
/** Current remove buttons, at most one consumer-resolved button per chip item. */
|
|
156
291
|
get buttons() {
|
|
157
292
|
return this.#entries.map(({ button }) => button);
|
|
158
293
|
}
|
|
159
|
-
/** Number of currently navigable remove buttons. */
|
|
160
294
|
get length() {
|
|
161
295
|
return this.buttons.length;
|
|
162
296
|
}
|
|
163
|
-
/** Declared-item index of the last navigable chip, or `-1` for an empty row. */
|
|
164
297
|
get lastIndex() {
|
|
165
298
|
return this.#entries.at(-1)?.itemIndex ?? -1;
|
|
166
299
|
}
|
|
167
|
-
/** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
|
|
168
300
|
ensureTabStop() {
|
|
169
301
|
const buttons = this.#entries.map(({ button }) => button);
|
|
170
302
|
const active = buttons.findIndex((button) => button.tabIndex === 0);
|
|
@@ -173,7 +305,6 @@ var ChipRow = class {
|
|
|
173
305
|
this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
|
|
174
306
|
}
|
|
175
307
|
}
|
|
176
|
-
/** Focuses the last chip, returning false for an empty row. */
|
|
177
308
|
focusLast() {
|
|
178
309
|
const buttons = this.#entries.map(({ button }) => button);
|
|
179
310
|
const last = buttons.length - 1;
|
|
@@ -181,10 +312,6 @@ var ChipRow = class {
|
|
|
181
312
|
this.#roving.setActive(last, { focus: true, items: buttons });
|
|
182
313
|
return true;
|
|
183
314
|
}
|
|
184
|
-
/**
|
|
185
|
-
* Focuses the chip that followed a removed index, or the new last chip.
|
|
186
|
-
* Returns false when removal emptied the row so the consumer can rescue focus.
|
|
187
|
-
*/
|
|
188
315
|
focusAfterRemoval(index) {
|
|
189
316
|
const entries = this.#entries;
|
|
190
317
|
if (entries.length === 0) return false;
|
|
@@ -195,14 +322,12 @@ var ChipRow = class {
|
|
|
195
322
|
});
|
|
196
323
|
return true;
|
|
197
324
|
}
|
|
198
|
-
/** Current remove-button entries, retaining each button's declared item index. */
|
|
199
325
|
get #entries() {
|
|
200
326
|
return this.#getItems().flatMap((item, itemIndex) => {
|
|
201
327
|
const button = this.#getButton(item);
|
|
202
328
|
return button ? [{ button, itemIndex }] : [];
|
|
203
329
|
});
|
|
204
330
|
}
|
|
205
|
-
/** Resolves a delegated event to the remove button owned by a declared chip. */
|
|
206
331
|
#entry(event) {
|
|
207
332
|
const target = event.target;
|
|
208
333
|
if (!(target instanceof Element)) return null;
|
|
@@ -213,13 +338,11 @@ var ChipRow = class {
|
|
|
213
338
|
const entry = entries[buttonIndex];
|
|
214
339
|
return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
|
|
215
340
|
}
|
|
216
|
-
/** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
|
|
217
341
|
#onClick = (event) => {
|
|
218
342
|
const entry = this.#entry(event);
|
|
219
343
|
if (!entry) return;
|
|
220
344
|
this.#onRemove(entry.itemIndex);
|
|
221
345
|
};
|
|
222
|
-
/** Applies the shared logical-arrow and removal policy within the chip row. */
|
|
223
346
|
#onKeydown = (event) => {
|
|
224
347
|
if (event.defaultPrevented || isReservedArrowChord(event)) return;
|
|
225
348
|
const entry = this.#entry(event);
|
|
@@ -250,7 +373,6 @@ var ChipRow = class {
|
|
|
250
373
|
};
|
|
251
374
|
};
|
|
252
375
|
|
|
253
|
-
// src/utils/composition_tracker.ts
|
|
254
376
|
var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
255
377
|
"insertCompositionText",
|
|
256
378
|
"insertFromComposition",
|
|
@@ -262,13 +384,11 @@ var CompositionTracker = class {
|
|
|
262
384
|
#activeTargets = /* @__PURE__ */ new Set();
|
|
263
385
|
#onStart;
|
|
264
386
|
#onEnd;
|
|
265
|
-
/** The field whose confirming `input` is still owed, while the window is open. */
|
|
266
387
|
#confirmedTarget = null;
|
|
267
388
|
constructor(options = {}) {
|
|
268
389
|
this.#onStart = options.onStart;
|
|
269
390
|
this.#onEnd = options.onEnd;
|
|
270
391
|
}
|
|
271
|
-
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
272
392
|
observe(target) {
|
|
273
393
|
if (this.#observedTargets.has(target)) return;
|
|
274
394
|
target.addEventListener("compositionstart", this.#handleStart);
|
|
@@ -276,7 +396,6 @@ var CompositionTracker = class {
|
|
|
276
396
|
target.addEventListener("keydown", this.#handleKeydown);
|
|
277
397
|
this.#observedTargets.add(target);
|
|
278
398
|
}
|
|
279
|
-
/** Stops tracking one target and clears any active composition it owned. */
|
|
280
399
|
unobserve(target) {
|
|
281
400
|
if (!this.#observedTargets.delete(target)) return;
|
|
282
401
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
@@ -285,7 +404,6 @@ var CompositionTracker = class {
|
|
|
285
404
|
this.#activeTargets.delete(target);
|
|
286
405
|
if (this.#confirmedTarget === target) this.#confirmedTarget = null;
|
|
287
406
|
}
|
|
288
|
-
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
289
407
|
disconnect() {
|
|
290
408
|
for (const target of this.#observedTargets) {
|
|
291
409
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
@@ -296,16 +414,9 @@ var CompositionTracker = class {
|
|
|
296
414
|
this.#activeTargets.clear();
|
|
297
415
|
this.#confirmedTarget = null;
|
|
298
416
|
}
|
|
299
|
-
/** True when lifecycle tracking or the current event reports composition. */
|
|
300
417
|
isComposing(event) {
|
|
301
418
|
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
302
419
|
}
|
|
303
|
-
/**
|
|
304
|
-
* Whether `event` is the `input` echoing the composition just confirmed.
|
|
305
|
-
*
|
|
306
|
-
* Asking closes the window either way, so one confirmation is folded at most
|
|
307
|
-
* once and a consumer asks once per `input`.
|
|
308
|
-
*/
|
|
309
420
|
consumesConfirmedInput(event) {
|
|
310
421
|
const confirmed = this.#confirmedTarget;
|
|
311
422
|
this.#confirmedTarget = null;
|
|
@@ -323,13 +434,11 @@ var CompositionTracker = class {
|
|
|
323
434
|
this.#confirmedTarget = event.target;
|
|
324
435
|
this.#onEnd?.(event);
|
|
325
436
|
};
|
|
326
|
-
/** A key on an observed field opens an edit of its own, so no echo is owed. */
|
|
327
437
|
#handleKeydown = () => {
|
|
328
438
|
this.#confirmedTarget = null;
|
|
329
439
|
};
|
|
330
440
|
};
|
|
331
441
|
|
|
332
|
-
// src/utils/field_mirror.ts
|
|
333
442
|
function writeFields(container, values, { name, form = "" }) {
|
|
334
443
|
const current = [...container.children];
|
|
335
444
|
const submits = current.length === values.length && current.every(
|
|
@@ -352,27 +461,33 @@ function commitField(target) {
|
|
|
352
461
|
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
353
462
|
}
|
|
354
463
|
|
|
355
|
-
|
|
464
|
+
var ListenerSet = class {
|
|
465
|
+
#abort = new AbortController();
|
|
466
|
+
add(target, type, handler, options) {
|
|
467
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
468
|
+
}
|
|
469
|
+
dispose() {
|
|
470
|
+
this.#abort.abort();
|
|
471
|
+
this.#abort = new AbortController();
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
356
475
|
var MicrotaskCoalescer = class {
|
|
357
476
|
#run;
|
|
358
477
|
#queued = false;
|
|
359
478
|
#active = false;
|
|
360
479
|
#generation = 0;
|
|
361
|
-
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
362
480
|
constructor(run) {
|
|
363
481
|
this.#run = run;
|
|
364
482
|
}
|
|
365
|
-
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
366
483
|
activate() {
|
|
367
484
|
this.#active = true;
|
|
368
485
|
}
|
|
369
|
-
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
370
486
|
cancel() {
|
|
371
487
|
this.#active = false;
|
|
372
488
|
this.#queued = false;
|
|
373
489
|
this.#generation += 1;
|
|
374
490
|
}
|
|
375
|
-
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
376
491
|
schedule() {
|
|
377
492
|
if (!this.#active || this.#queued) return;
|
|
378
493
|
this.#queued = true;
|
|
@@ -385,7 +500,86 @@ var MicrotaskCoalescer = class {
|
|
|
385
500
|
}
|
|
386
501
|
};
|
|
387
502
|
|
|
388
|
-
|
|
503
|
+
var MorphRenderWatcher = class {
|
|
504
|
+
#listeners = new ListenerSet();
|
|
505
|
+
#pass;
|
|
506
|
+
#element = null;
|
|
507
|
+
#onMorph = () => this.#pass.schedule();
|
|
508
|
+
constructor(run) {
|
|
509
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
510
|
+
}
|
|
511
|
+
observe(element) {
|
|
512
|
+
if (this.#element === element) return;
|
|
513
|
+
this.disconnect();
|
|
514
|
+
this.#element = element;
|
|
515
|
+
this.#pass.activate();
|
|
516
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
517
|
+
}
|
|
518
|
+
schedule() {
|
|
519
|
+
this.#pass.schedule();
|
|
520
|
+
}
|
|
521
|
+
disconnect() {
|
|
522
|
+
this.#listeners.dispose();
|
|
523
|
+
this.#pass.cancel();
|
|
524
|
+
this.#element = null;
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
var MoveCounter = class {
|
|
529
|
+
#sequence = 0;
|
|
530
|
+
record() {
|
|
531
|
+
this.#sequence += 1;
|
|
532
|
+
return this.#sequence;
|
|
533
|
+
}
|
|
534
|
+
isLatest(token) {
|
|
535
|
+
return token === this.#sequence;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
var NUMBER_BOUNDS = {
|
|
540
|
+
finite: { finite: true }};
|
|
541
|
+
function matchesNumberBounds(value, bounds) {
|
|
542
|
+
if (!Number.isFinite(value)) {
|
|
543
|
+
const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
|
|
544
|
+
if (direction === null) return false;
|
|
545
|
+
if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
|
|
546
|
+
}
|
|
547
|
+
if (bounds.min !== void 0 && value < bounds.min) return false;
|
|
548
|
+
if (bounds.max !== void 0 && value > bounds.max) return false;
|
|
549
|
+
if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
|
|
550
|
+
if (bounds.integer && !Number.isInteger(value)) return false;
|
|
551
|
+
if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function readNumber(raw, fallback, bounds) {
|
|
556
|
+
return matchesNumberBounds(raw, bounds) ? raw : fallback;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
var NumberValueReader = class {
|
|
560
|
+
#lastRejected = /* @__PURE__ */ new Map();
|
|
561
|
+
read(owner, name, raw, fallback, bounds) {
|
|
562
|
+
const resolved = readNumber(raw, fallback, bounds);
|
|
563
|
+
if (matchesNumberBounds(raw, bounds)) {
|
|
564
|
+
this.#lastRejected.delete(name);
|
|
565
|
+
return resolved;
|
|
566
|
+
}
|
|
567
|
+
const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
|
|
568
|
+
const literal = owner.element.getAttribute(attribute);
|
|
569
|
+
if (literal === null) {
|
|
570
|
+
this.#lastRejected.delete(name);
|
|
571
|
+
return resolved;
|
|
572
|
+
}
|
|
573
|
+
if (this.#lastRejected.get(name) !== literal) {
|
|
574
|
+
this.#lastRejected.set(name, literal);
|
|
575
|
+
console.warn(
|
|
576
|
+
`Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return resolved;
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
389
583
|
function scrollOptionIntoView(list, option) {
|
|
390
584
|
if (list.scrollHeight <= list.clientHeight) return;
|
|
391
585
|
const listRect = list.getBoundingClientRect();
|
|
@@ -397,16 +591,20 @@ function scrollOptionIntoView(list, option) {
|
|
|
397
591
|
}
|
|
398
592
|
}
|
|
399
593
|
|
|
400
|
-
|
|
401
|
-
|
|
594
|
+
var StateRegions = class _StateRegions {
|
|
595
|
+
static #registry = sharedRegistry(
|
|
596
|
+
"stimeo-ui.state-regions.registry.v1",
|
|
597
|
+
() => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
|
|
598
|
+
);
|
|
402
599
|
#whenTrue;
|
|
403
600
|
#whenFalse;
|
|
404
|
-
#
|
|
405
|
-
constructor(options) {
|
|
601
|
+
#record;
|
|
602
|
+
constructor(options, owner) {
|
|
406
603
|
this.#whenTrue = options.whenTrue;
|
|
407
604
|
this.#whenFalse = options.whenFalse ?? null;
|
|
605
|
+
this.#record = `data-${owner}-hidden-region`;
|
|
606
|
+
_StateRegions.#registry.names.add(this.#record);
|
|
408
607
|
}
|
|
409
|
-
/** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
|
|
410
608
|
reflect(host, isTrue) {
|
|
411
609
|
const shown = this.#inside(host, this.#whenTrue());
|
|
412
610
|
if (!this.#whenFalse) {
|
|
@@ -422,88 +620,231 @@ var StateRegions = class {
|
|
|
422
620
|
for (const region of shown) this.#write(region, !isTrue);
|
|
423
621
|
for (const region of hiddenSide) this.#write(region, isTrue);
|
|
424
622
|
}
|
|
425
|
-
|
|
623
|
+
release(host) {
|
|
624
|
+
for (const region of [host, ...host.querySelectorAll("*")]) {
|
|
625
|
+
if (!(region instanceof HTMLElement)) continue;
|
|
626
|
+
const stack = this.#held(region);
|
|
627
|
+
const hold = stack && this.#take(stack);
|
|
628
|
+
if (!stack || !hold) continue;
|
|
629
|
+
const owns = region.hidden === hold.written && !this.#covered(region, stack, hold);
|
|
630
|
+
const index = stack.indexOf(hold);
|
|
631
|
+
stack.splice(index, 1);
|
|
632
|
+
const above = stack[index];
|
|
633
|
+
if (above) above.linked &&= hold.linked;
|
|
634
|
+
if (owns) {
|
|
635
|
+
const below = hold.linked ? stack[index - 1] : void 0;
|
|
636
|
+
if (below) {
|
|
637
|
+
if (region.hidden !== below.written) region.hidden = below.written;
|
|
638
|
+
} else if (region.getAttribute("hidden") !== hold.authored) {
|
|
639
|
+
if (hold.authored === null) region.removeAttribute("hidden");
|
|
640
|
+
else region.setAttribute("hidden", hold.authored);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
this.#mark(region, stack);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
426
646
|
#write(region, hidden) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
647
|
+
const stack = this.#stack(region);
|
|
648
|
+
const hold = this.#take(stack) ?? this.#acquire(region, stack);
|
|
649
|
+
const shows = !this.#covered(region, stack, hold);
|
|
650
|
+
hold.written = hidden;
|
|
651
|
+
if (shows && region.hidden !== hidden) region.hidden = hidden;
|
|
652
|
+
this.#mark(region, stack);
|
|
430
653
|
}
|
|
431
|
-
/** Returns a region this instance hid; one it never hid keeps what it carries. */
|
|
432
654
|
#give(region) {
|
|
433
|
-
|
|
655
|
+
const stack = this.#held(region);
|
|
656
|
+
if (stack && this.#take(stack)?.written) this.#write(region, false);
|
|
657
|
+
}
|
|
658
|
+
#held(region) {
|
|
659
|
+
return _StateRegions.#registry.stacks.has(region) || region.hasAttribute(this.#record) ? this.#stack(region) : void 0;
|
|
660
|
+
}
|
|
661
|
+
#stack(region) {
|
|
662
|
+
const stacks = _StateRegions.#registry.stacks;
|
|
663
|
+
const stack = stacks.get(region) ?? this.#seed(region);
|
|
664
|
+
stacks.set(region, stack);
|
|
665
|
+
return stack;
|
|
666
|
+
}
|
|
667
|
+
#seed(region) {
|
|
668
|
+
const found = [];
|
|
669
|
+
for (const record of _StateRegions.#registry.names) {
|
|
670
|
+
const read = this.#recorded(region, record);
|
|
671
|
+
if (read) found.push({ ...read, record });
|
|
672
|
+
}
|
|
673
|
+
const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
|
|
674
|
+
return found.sort((one, other) => depth(one) - depth(other)).map(({ record, authored, written, linked }, index) => ({
|
|
675
|
+
instance: null,
|
|
676
|
+
record,
|
|
677
|
+
authored,
|
|
678
|
+
written,
|
|
679
|
+
linked: linked && index > 0
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
682
|
+
#take(stack) {
|
|
683
|
+
const hold = stack.find((held) => held.instance === this) ?? stack.find((held) => held.instance === null && held.record === this.#record);
|
|
684
|
+
if (hold) hold.instance = this;
|
|
685
|
+
return hold;
|
|
686
|
+
}
|
|
687
|
+
#covered(region, stack, hold) {
|
|
688
|
+
return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === region.hidden);
|
|
689
|
+
}
|
|
690
|
+
#acquire(region, stack) {
|
|
691
|
+
const top = stack.at(-1);
|
|
692
|
+
const over = top && region.hidden === top.written ? top : void 0;
|
|
693
|
+
const hold = {
|
|
694
|
+
instance: this,
|
|
695
|
+
record: this.#record,
|
|
696
|
+
authored: over ? over.authored : region.getAttribute("hidden"),
|
|
697
|
+
written: false,
|
|
698
|
+
linked: over !== void 0
|
|
699
|
+
};
|
|
700
|
+
stack.push(hold);
|
|
701
|
+
return hold;
|
|
702
|
+
}
|
|
703
|
+
#mark(region, stack) {
|
|
704
|
+
const records = /* @__PURE__ */ new Map([[this.#record, null]]);
|
|
705
|
+
stack.forEach((hold, index) => {
|
|
706
|
+
const names = stack.slice(0, index).map((under) => under.record);
|
|
707
|
+
const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
|
|
708
|
+
const marker = hold.linked || beneath.length === 0 ? [] : [false];
|
|
709
|
+
records.set(
|
|
710
|
+
hold.record,
|
|
711
|
+
region.getAttribute("hidden") === hold.authored && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.authored, hold.written, ...marker, ...beneath])
|
|
712
|
+
);
|
|
713
|
+
});
|
|
714
|
+
for (const [name, recorded] of records) {
|
|
715
|
+
if (region.getAttribute(name) === recorded) continue;
|
|
716
|
+
if (recorded === null) region.removeAttribute(name);
|
|
717
|
+
else region.setAttribute(name, recorded);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
#recorded(region, name) {
|
|
721
|
+
try {
|
|
722
|
+
const parsed = JSON.parse(region.getAttribute(name) ?? "null");
|
|
723
|
+
if (!Array.isArray(parsed)) return null;
|
|
724
|
+
const [authored, written, ...rest] = parsed;
|
|
725
|
+
if (authored !== null && typeof authored !== "string") return null;
|
|
726
|
+
const linked = rest[0] !== false;
|
|
727
|
+
const beneath = linked ? rest : rest.slice(1);
|
|
728
|
+
if (!beneath.every((under) => typeof under === "string")) return null;
|
|
729
|
+
return typeof written === "boolean" ? { authored, written, linked, beneath } : null;
|
|
730
|
+
} catch {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
434
733
|
}
|
|
435
|
-
/** The declared regions that sit within `host`, which may be the host itself. */
|
|
436
734
|
#inside(host, regions) {
|
|
437
735
|
return regions.filter((region) => host.contains(region));
|
|
438
736
|
}
|
|
439
737
|
};
|
|
440
738
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
this.#rewind = rewind;
|
|
453
|
-
}
|
|
454
|
-
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
455
|
-
activate() {
|
|
456
|
-
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
457
|
-
_BeforeCacheReset.#subscribers.add(this);
|
|
458
|
-
if (first) {
|
|
459
|
-
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
739
|
+
function queryOpenDescendants(root, selector) {
|
|
740
|
+
const matches = [];
|
|
741
|
+
const roots = [root];
|
|
742
|
+
for (const scope of roots) {
|
|
743
|
+
matches.push(...scope.querySelectorAll(selector));
|
|
744
|
+
if (scope.nodeType === Node.ELEMENT_NODE) {
|
|
745
|
+
const shadow = scope.shadowRoot;
|
|
746
|
+
if (shadow) roots.push(shadow);
|
|
747
|
+
}
|
|
748
|
+
for (const element of scope.querySelectorAll("*")) {
|
|
749
|
+
if (element.shadowRoot) roots.push(element.shadowRoot);
|
|
460
750
|
}
|
|
461
751
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
_BeforeCacheReset.#subscribers.delete(this);
|
|
465
|
-
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
466
|
-
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
467
|
-
}
|
|
468
|
-
};
|
|
752
|
+
return matches;
|
|
753
|
+
}
|
|
469
754
|
|
|
470
|
-
|
|
471
|
-
|
|
755
|
+
var TabindexLoan = class _TabindexLoan {
|
|
756
|
+
static #registry = sharedRegistry(
|
|
757
|
+
"stimeo-ui.tabindex-loan.registry.v1",
|
|
758
|
+
() => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
|
|
759
|
+
);
|
|
472
760
|
#value;
|
|
761
|
+
#record;
|
|
473
762
|
#lent = /* @__PURE__ */ new Set();
|
|
474
|
-
|
|
475
|
-
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
476
|
-
/**
|
|
477
|
-
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
478
|
-
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
479
|
-
* which a scroll region with no focusable content of its own needs.
|
|
480
|
-
*/
|
|
481
|
-
constructor(value = "-1") {
|
|
763
|
+
constructor(value, owner) {
|
|
482
764
|
this.#value = value;
|
|
765
|
+
this.#record = `data-${owner}-tabindex-loan`;
|
|
766
|
+
_TabindexLoan.#registry.names.add(this.#record);
|
|
483
767
|
}
|
|
484
|
-
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
485
768
|
lend(element) {
|
|
486
|
-
|
|
487
|
-
element.
|
|
769
|
+
const stack = this.#stack(element);
|
|
770
|
+
const current = element.getAttribute("tabindex");
|
|
771
|
+
const held = stack.find((hold) => hold.loan === this) ?? stack.find(
|
|
772
|
+
(hold) => hold.loan === null && hold.record === this.#record && hold.value === this.#value
|
|
773
|
+
);
|
|
774
|
+
const linked = stack.at(-1)?.value === current;
|
|
775
|
+
if (!held && current !== null && !linked) return;
|
|
488
776
|
this.#lent.add(element);
|
|
489
|
-
|
|
777
|
+
if (held) held.loan = this;
|
|
778
|
+
else stack.push({ loan: this, record: this.#record, value: this.#value, linked });
|
|
779
|
+
this.#mark(element, stack);
|
|
780
|
+
if (current === null || !held && current !== this.#value) {
|
|
781
|
+
element.setAttribute("tabindex", this.#value);
|
|
782
|
+
}
|
|
490
783
|
}
|
|
491
|
-
/** Takes back every loan whose value is still the one that was lent. */
|
|
492
784
|
returnAll() {
|
|
493
785
|
for (const element of this.#lent) {
|
|
494
|
-
|
|
786
|
+
const stack = this.#stack(element);
|
|
787
|
+
const hold = stack.find((held) => held.loan === this);
|
|
788
|
+
if (hold) this.#release(element, stack, hold);
|
|
495
789
|
}
|
|
496
790
|
this.#lent.clear();
|
|
497
|
-
|
|
791
|
+
}
|
|
792
|
+
reclaim(element) {
|
|
793
|
+
if (element.getAttribute(this.#record) !== this.#value) return;
|
|
794
|
+
const stack = this.#stack(element);
|
|
795
|
+
const hold = stack.find((held) => held.loan === null && held.record === this.#record);
|
|
796
|
+
if (hold && !element.matches(":focus")) this.#release(element, stack, hold);
|
|
797
|
+
}
|
|
798
|
+
reclaimWithin(root) {
|
|
799
|
+
for (const element of queryOpenDescendants(root, `[${this.#record}]`)) {
|
|
800
|
+
this.reclaim(element);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
#stack(element) {
|
|
804
|
+
const stacks = _TabindexLoan.#registry.stacks;
|
|
805
|
+
const stack = stacks.get(element) ?? this.#seed(element);
|
|
806
|
+
stacks.set(element, stack);
|
|
807
|
+
return stack;
|
|
808
|
+
}
|
|
809
|
+
#seed(element) {
|
|
810
|
+
const current = element.getAttribute("tabindex");
|
|
811
|
+
const holds = [];
|
|
812
|
+
for (const record of _TabindexLoan.#registry.names) {
|
|
813
|
+
const value = element.getAttribute(record);
|
|
814
|
+
if (value !== null) holds.push({ loan: null, record, value, linked: true });
|
|
815
|
+
}
|
|
816
|
+
holds.sort((one, other) => Number(one.value === current) - Number(other.value === current));
|
|
817
|
+
for (const [index, hold] of holds.entries()) hold.linked = index > 0;
|
|
818
|
+
return holds;
|
|
819
|
+
}
|
|
820
|
+
#release(element, stack, hold) {
|
|
821
|
+
const current = element.getAttribute("tabindex");
|
|
822
|
+
const index = stack.indexOf(hold);
|
|
823
|
+
const owns = current === hold.value && !stack.slice(index + 1).some((above2) => above2.value === current);
|
|
824
|
+
stack.splice(index, 1);
|
|
825
|
+
const above = stack[index];
|
|
826
|
+
if (above) above.linked &&= hold.linked;
|
|
827
|
+
this.#mark(element, stack);
|
|
828
|
+
if (!owns) return;
|
|
829
|
+
const below = hold.linked ? stack[index - 1] : void 0;
|
|
830
|
+
if (!below) element.removeAttribute("tabindex");
|
|
831
|
+
else if (below.value !== current) element.setAttribute("tabindex", below.value);
|
|
832
|
+
}
|
|
833
|
+
#mark(element, stack) {
|
|
834
|
+
const records = /* @__PURE__ */ new Map([[this.#record, null]]);
|
|
835
|
+
for (const hold of stack) records.set(hold.record, hold.value);
|
|
836
|
+
for (const [name, recorded] of records) {
|
|
837
|
+
if (element.getAttribute(name) === recorded) continue;
|
|
838
|
+
if (recorded === null) element.removeAttribute(name);
|
|
839
|
+
else element.setAttribute(name, recorded);
|
|
840
|
+
}
|
|
498
841
|
}
|
|
499
842
|
};
|
|
500
843
|
|
|
501
|
-
// src/utils/target_selector.ts
|
|
502
844
|
function targetSelector(identifier, name) {
|
|
503
845
|
return `[data-${identifier}-target~="${name}"]`;
|
|
504
846
|
}
|
|
505
847
|
|
|
506
|
-
// src/utils/template_row.ts
|
|
507
848
|
function cloneTemplateRoot(template) {
|
|
508
849
|
const root = template.content.firstElementChild;
|
|
509
850
|
return root instanceof HTMLElement ? root.cloneNode(true) : null;
|
|
@@ -514,22 +855,15 @@ var TemplateRow = class {
|
|
|
514
855
|
constructor(options) {
|
|
515
856
|
this.#options = options;
|
|
516
857
|
}
|
|
517
|
-
/** The attribute selector for one declared part, in this controller's namespace. */
|
|
518
858
|
selector(name) {
|
|
519
859
|
return targetSelector(this.#options.identifier, name);
|
|
520
860
|
}
|
|
521
|
-
/** Re-arms the once-per-connection diagnostic. */
|
|
522
861
|
connect() {
|
|
523
862
|
this.#warned = false;
|
|
524
863
|
}
|
|
525
|
-
/**
|
|
526
|
-
* Names one missing part on the console, at most once per connection, and
|
|
527
|
-
* returns `null` so a caller can hand it straight back.
|
|
528
|
-
*/
|
|
529
864
|
report(missing) {
|
|
530
865
|
return this.#say(`lacks ${missing}`);
|
|
531
866
|
}
|
|
532
|
-
/** Writes one diagnostic per connection, saying what the template got wrong. */
|
|
533
867
|
#say(problem) {
|
|
534
868
|
if (this.#warned) return null;
|
|
535
869
|
this.#warned = true;
|
|
@@ -537,10 +871,6 @@ var TemplateRow = class {
|
|
|
537
871
|
console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
|
|
538
872
|
return null;
|
|
539
873
|
}
|
|
540
|
-
/**
|
|
541
|
-
* Clones the row and resolves its parts, or names what the template got wrong
|
|
542
|
-
* and returns `null`. `values` fills the button's authored `aria-label`.
|
|
543
|
-
*/
|
|
544
874
|
instantiate(template, values) {
|
|
545
875
|
const { root: rootName, required, optional, button: buttonName } = this.#options;
|
|
546
876
|
const root = cloneTemplateRoot(template);
|
|
@@ -568,7 +898,6 @@ var TemplateRow = class {
|
|
|
568
898
|
button.setAttribute("aria-label", fillTemplate(name, values));
|
|
569
899
|
return { root, slots, button };
|
|
570
900
|
}
|
|
571
|
-
/** The row may be its own part; otherwise the first descendant carrying the target. */
|
|
572
901
|
#resolve(root, name) {
|
|
573
902
|
return matchingPart(root, this.selector(name));
|
|
574
903
|
}
|
|
@@ -577,8 +906,9 @@ var TemplateRow = class {
|
|
|
577
906
|
}
|
|
578
907
|
};
|
|
579
908
|
|
|
580
|
-
|
|
581
|
-
|
|
909
|
+
var MultiSelectController = class _MultiSelectController extends Controller {
|
|
910
|
+
#numbers = new NumberValueReader();
|
|
911
|
+
#moves = new MoveCounter();
|
|
582
912
|
static targets = [
|
|
583
913
|
"input",
|
|
584
914
|
"list",
|
|
@@ -598,28 +928,28 @@ var MultiSelectController = class extends Controller {
|
|
|
598
928
|
announceText: { type: String, default: "" },
|
|
599
929
|
announceRemovedText: { type: String, default: "" }
|
|
600
930
|
};
|
|
931
|
+
static valueConstraints = {
|
|
932
|
+
max: NUMBER_BOUNDS.finite
|
|
933
|
+
};
|
|
601
934
|
static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
|
|
602
935
|
static events = ["change", "filter", "reconcile"];
|
|
603
|
-
/** Stable id of the active option; the current target is resolved from the DOM. */
|
|
604
936
|
#activeOptionId = null;
|
|
605
|
-
|
|
606
|
-
#tabindex = new TabindexLoan();
|
|
607
|
-
/** Last reconciled selection, used to distinguish state changes from derived-DOM repair. */
|
|
937
|
+
#tabindex = new TabindexLoan("-1", this.identifier);
|
|
608
938
|
#selectionValues = [];
|
|
609
|
-
/** Whether initial normalization finished, so a fields callback cannot mirror stale state. */
|
|
610
939
|
#connected = false;
|
|
611
|
-
|
|
612
|
-
#
|
|
613
|
-
|
|
614
|
-
#
|
|
615
|
-
|
|
940
|
+
#emptyRegion = new StateRegions({ whenTrue: () => this.emptyTargets }, this.identifier);
|
|
941
|
+
#listHidden = new AttributeLease("hidden", this.identifier);
|
|
942
|
+
#expanded = new AttributeLease("aria-expanded", this.identifier);
|
|
943
|
+
#activeDescendant = new AttributeLease(
|
|
944
|
+
"aria-activedescendant",
|
|
945
|
+
this.identifier
|
|
946
|
+
);
|
|
947
|
+
#open = false;
|
|
948
|
+
#openList = null;
|
|
949
|
+
#reconcile = new MorphRenderWatcher(() => this.#reconcileOptions());
|
|
616
950
|
#composition = new CompositionTracker({
|
|
617
|
-
// Apply the confirmed query even in browsers that omit a final input event;
|
|
618
|
-
// where one follows, the tracker folds it so consumers receive one filter
|
|
619
|
-
// event for the confirmed text, not two.
|
|
620
951
|
onEnd: () => this.filter()
|
|
621
952
|
});
|
|
622
|
-
/** Builds one chip from the authored template and owns its diagnostic. */
|
|
623
953
|
#rows = new TemplateRow({
|
|
624
954
|
identifier: this.identifier,
|
|
625
955
|
root: "tag",
|
|
@@ -628,7 +958,6 @@ var MultiSelectController = class extends Controller {
|
|
|
628
958
|
outcome: "changed no selection",
|
|
629
959
|
noun: "chip template"
|
|
630
960
|
});
|
|
631
|
-
/** Shared delegated interaction for the replaceable row of removable chips. */
|
|
632
961
|
#chipRow = new ChipRow({
|
|
633
962
|
directionElement: this.element,
|
|
634
963
|
getItems: () => this.tagTargets,
|
|
@@ -636,8 +965,8 @@ var MultiSelectController = class extends Controller {
|
|
|
636
965
|
onRemove: (index) => this.#removeTagAt(index),
|
|
637
966
|
focusAfterEnd: () => this.#focusInput()
|
|
638
967
|
});
|
|
639
|
-
/** Starts closed, syncs chips for any pre-selected options, and listens out. */
|
|
640
968
|
connect() {
|
|
969
|
+
this.#tabindex.reclaim(this.element);
|
|
641
970
|
this.#rows.connect();
|
|
642
971
|
this.#normalizeSelection([]);
|
|
643
972
|
this.close();
|
|
@@ -649,13 +978,8 @@ var MultiSelectController = class extends Controller {
|
|
|
649
978
|
this.#syncFields();
|
|
650
979
|
document.addEventListener("click", this.#onOutsideClick, true);
|
|
651
980
|
this.#connected = true;
|
|
652
|
-
this.#reconcile.
|
|
981
|
+
this.#reconcile.observe(this.element);
|
|
653
982
|
}
|
|
654
|
-
/**
|
|
655
|
-
* Derives the chips from the selected options, idempotently: a Turbo Drive cache
|
|
656
|
-
* restore or morph can re-connect with chips already in the DOM, so they are
|
|
657
|
-
* cleared before deriving afresh to avoid duplicates.
|
|
658
|
-
*/
|
|
659
983
|
#rebuildTags() {
|
|
660
984
|
if (!this.hasTagsTarget || !this.hasTagTemplateTarget) return;
|
|
661
985
|
const chips = [];
|
|
@@ -668,72 +992,67 @@ var MultiSelectController = class extends Controller {
|
|
|
668
992
|
this.tagsTarget.append(...chips);
|
|
669
993
|
this.#chipRow.ensureTabStop();
|
|
670
994
|
}
|
|
671
|
-
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
672
995
|
disconnect() {
|
|
673
996
|
this.#connected = false;
|
|
674
|
-
this.#reconcile.
|
|
997
|
+
this.#reconcile.disconnect();
|
|
675
998
|
this.#composition.disconnect();
|
|
676
999
|
this.#chipRow.disconnect();
|
|
677
1000
|
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
678
1001
|
this.#releaseTabindex();
|
|
679
1002
|
}
|
|
680
|
-
/** Reconciles active state after an option target is added at runtime. */
|
|
681
1003
|
optionTargetConnected() {
|
|
682
1004
|
this.#scheduleOptionReconcile();
|
|
683
1005
|
}
|
|
684
|
-
/** Cleans a removed target and reconciles active state against the surviving DOM. */
|
|
685
1006
|
optionTargetDisconnected(option) {
|
|
686
1007
|
option.removeAttribute("data-active");
|
|
687
1008
|
this.#scheduleOptionReconcile();
|
|
688
1009
|
}
|
|
689
|
-
/** Settles a region inserted after connect on the side of the current state. */
|
|
690
1010
|
emptyTargetConnected() {
|
|
691
1011
|
if (this.#connected) this.#reflectEmpty();
|
|
692
1012
|
}
|
|
693
|
-
/** Rebinds delegated chip interaction when Turbo replaces the tags container. */
|
|
694
1013
|
tagsTargetConnected(tags) {
|
|
695
1014
|
this.#chipRow.connect(tags);
|
|
696
1015
|
this.#scheduleOptionReconcile();
|
|
697
1016
|
}
|
|
698
|
-
/** Releases only the row that actually disconnected, never a newer replacement. */
|
|
699
1017
|
tagsTargetDisconnected(tags) {
|
|
700
1018
|
this.#chipRow.disconnect(tags);
|
|
701
1019
|
this.#scheduleOptionReconcile();
|
|
702
1020
|
}
|
|
703
|
-
/** Seeds a fields target inserted after connect from the current selection. */
|
|
704
1021
|
fieldsTargetConnected() {
|
|
705
1022
|
if (this.#connected) this.#syncFields();
|
|
706
1023
|
}
|
|
707
|
-
|
|
1024
|
+
fieldsTargetDisconnected() {
|
|
1025
|
+
this.#scheduleOptionReconcile();
|
|
1026
|
+
}
|
|
1027
|
+
listTargetConnected(_list) {
|
|
1028
|
+
if (!this.#connected) return;
|
|
1029
|
+
this.#reconcileOpenState();
|
|
1030
|
+
this.#scheduleOptionReconcile();
|
|
1031
|
+
}
|
|
1032
|
+
listTargetDisconnected(list) {
|
|
1033
|
+
if (!this.listTargets.includes(list)) {
|
|
1034
|
+
if (list === this.#openList) {
|
|
1035
|
+
this.#adoptPageClose();
|
|
1036
|
+
this.#openList = null;
|
|
1037
|
+
}
|
|
1038
|
+
this.#listHidden.return(list);
|
|
1039
|
+
}
|
|
1040
|
+
if (!this.#connected) return;
|
|
1041
|
+
this.#reconcileOpenState();
|
|
1042
|
+
this.#scheduleOptionReconcile();
|
|
1043
|
+
}
|
|
708
1044
|
maxValueChanged() {
|
|
709
1045
|
this.#scheduleOptionReconcile();
|
|
710
1046
|
}
|
|
711
|
-
/** Rebuilds submitted fields when their public name changes at runtime. */
|
|
712
1047
|
nameValueChanged() {
|
|
713
1048
|
this.#scheduleOptionReconcile();
|
|
714
1049
|
}
|
|
715
|
-
/** Rebuilds submitted fields when their associated form changes at runtime. */
|
|
716
1050
|
formValueChanged() {
|
|
717
1051
|
this.#scheduleOptionReconcile();
|
|
718
1052
|
}
|
|
719
|
-
/** Schedules one reconciliation after all callbacks in the mutation batch. */
|
|
720
1053
|
#scheduleOptionReconcile() {
|
|
721
1054
|
this.#reconcile.schedule();
|
|
722
1055
|
}
|
|
723
|
-
/**
|
|
724
|
-
* Keeps a surviving/same-id active target, otherwise falls back to the first
|
|
725
|
-
* visible one — and brings the derived state back in line with the new option set.
|
|
726
|
-
*
|
|
727
|
-
* The baseline pass fills in any missing `aria-selected`, and the chips and
|
|
728
|
-
* hidden fields are re-derived from it, because the options are the truth source
|
|
729
|
-
* for the selection. The chips are rebuilt **only when the selected value set
|
|
730
|
-
* actually moved**: the rebuild removes and recreates every chip, so running it
|
|
731
|
-
* for an unrelated option would drop focus from a chip's remove button to
|
|
732
|
-
* `<body>`, losing the keyboard user's place for something that did not concern
|
|
733
|
-
* them.
|
|
734
|
-
*
|
|
735
|
-
* @stimeoRenderRoot
|
|
736
|
-
*/
|
|
737
1056
|
#reconcileOptions() {
|
|
738
1057
|
const visible = this.#visibleOptions;
|
|
739
1058
|
const active = this.#activeOption;
|
|
@@ -762,24 +1081,23 @@ var MultiSelectController = class extends Controller {
|
|
|
762
1081
|
const changed = !this.#sameSelection(previous, values);
|
|
763
1082
|
this.#selectionValues = values;
|
|
764
1083
|
if (!changed) return;
|
|
1084
|
+
const token = this.#moves.record();
|
|
765
1085
|
const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
|
|
766
1086
|
for (const value of previous) {
|
|
1087
|
+
if (!this.#moves.isLatest(token)) return;
|
|
767
1088
|
if (!values.includes(value)) {
|
|
768
1089
|
this.#announceTransition(false, previousLabels.get(value) || value, value, values.length);
|
|
769
1090
|
}
|
|
770
1091
|
}
|
|
771
1092
|
for (const value of values) {
|
|
1093
|
+
if (!this.#moves.isLatest(token)) return;
|
|
772
1094
|
if (previous.includes(value)) continue;
|
|
773
1095
|
const option = options.get(value);
|
|
774
1096
|
this.#announceTransition(true, this.#optionLabel(option), value, values.length);
|
|
775
1097
|
}
|
|
1098
|
+
if (!this.#moves.isLatest(token)) return;
|
|
776
1099
|
this.dispatch("reconcile", { detail: { values } });
|
|
777
1100
|
}
|
|
778
|
-
/**
|
|
779
|
-
* Gives every option an explicit `aria-selected` and enforces the current cap.
|
|
780
|
-
* Priority preserves existing chip order at runtime; a fresh connection passes
|
|
781
|
-
* no priority, so deterministic option DOM order chooses the initial survivors.
|
|
782
|
-
*/
|
|
783
1101
|
#normalizeSelection(priority) {
|
|
784
1102
|
const selected = this.optionTargets.filter(
|
|
785
1103
|
(option) => option.getAttribute("aria-selected") === "true"
|
|
@@ -805,25 +1123,36 @@ var MultiSelectController = class extends Controller {
|
|
|
805
1123
|
option.setAttribute("aria-selected", String(kept.has(option)));
|
|
806
1124
|
}
|
|
807
1125
|
}
|
|
808
|
-
/**
|
|
809
|
-
* Tracks an input added initially or after connect, and makes it describe the
|
|
810
|
-
* widget that is actually on screen: a swapped-in input arrives with the
|
|
811
|
-
* authored ARIA of a fresh node while this controller still holds the popup
|
|
812
|
-
* state, and the open path cannot repair that (it seeds an active option only
|
|
813
|
-
* when there is none), so a live list would go unannounced.
|
|
814
|
-
*/
|
|
815
1126
|
inputTargetConnected(input) {
|
|
816
1127
|
this.#composition.observe(input);
|
|
817
|
-
|
|
818
|
-
const active = this.#activeOption;
|
|
819
|
-
if (active) input.setAttribute("aria-activedescendant", ensureId(active, "stimeo-ms-opt"));
|
|
820
|
-
else input.removeAttribute("aria-activedescendant");
|
|
1128
|
+
this.#describeInput(input);
|
|
821
1129
|
}
|
|
822
|
-
/** Removes composition listeners when the active input is replaced or removed. */
|
|
823
1130
|
inputTargetDisconnected(input) {
|
|
824
1131
|
this.#composition.unobserve(input);
|
|
1132
|
+
if (!this.inputTargets.includes(input)) {
|
|
1133
|
+
this.#expanded.return(input);
|
|
1134
|
+
this.#activeDescendant.return(input);
|
|
1135
|
+
}
|
|
1136
|
+
if (this.#connected && this.hasInputTarget) this.#describeInput(this.inputTarget);
|
|
1137
|
+
}
|
|
1138
|
+
#describeInput(input) {
|
|
1139
|
+
this.#expanded.write(input, String(!this.#isClosed));
|
|
1140
|
+
const active = this.#activeOption;
|
|
1141
|
+
this.#activeDescendant.write(input, active ? ensureId(active, "stimeo-ms-opt") : null);
|
|
1142
|
+
}
|
|
1143
|
+
#reconcileOpenState() {
|
|
1144
|
+
if (!this.hasListTarget) return;
|
|
1145
|
+
this.#adoptPageClose();
|
|
1146
|
+
const list = this.listTarget;
|
|
1147
|
+
if (list !== this.#openList) {
|
|
1148
|
+
this.#showList(list, this.#open);
|
|
1149
|
+
this.#openList = list;
|
|
1150
|
+
}
|
|
1151
|
+
if (this.hasInputTarget) this.#expanded.write(this.inputTarget, String(!list.hidden));
|
|
1152
|
+
}
|
|
1153
|
+
#adoptPageClose() {
|
|
1154
|
+
if (this.#openList?.hidden) this.#open = false;
|
|
825
1155
|
}
|
|
826
|
-
/** Filters confirmed input text, opens, and re-seeds the active option. */
|
|
827
1156
|
filter(event) {
|
|
828
1157
|
if (!this.hasInputTarget) return;
|
|
829
1158
|
if (event && this.#composition.consumesConfirmedInput(event)) return;
|
|
@@ -835,46 +1164,31 @@ var MultiSelectController = class extends Controller {
|
|
|
835
1164
|
}
|
|
836
1165
|
this.open();
|
|
837
1166
|
const visible = this.#visibleOptions;
|
|
838
|
-
this.#reflectEmpty();
|
|
839
1167
|
this.#setActive(visible[0] ?? null);
|
|
840
1168
|
this.dispatch("filter", { detail: { query } });
|
|
841
1169
|
}
|
|
842
|
-
/**
|
|
843
|
-
* Opens the list and activates the first visible option when none is active.
|
|
844
|
-
*
|
|
845
|
-
* Needs the input, which owns `aria-expanded` and `aria-activedescendant`: a
|
|
846
|
-
* list shown without one is a popup no assistive technology is told about. So
|
|
847
|
-
* opening is skipped entirely, where {@link MultiSelectController.close | close}
|
|
848
|
-
* still closes.
|
|
849
|
-
*/
|
|
850
1170
|
open() {
|
|
851
1171
|
if (!this.hasListTarget || !this.hasInputTarget) {
|
|
852
1172
|
this.#reflectEmpty();
|
|
853
1173
|
return;
|
|
854
1174
|
}
|
|
855
|
-
this.listTarget
|
|
856
|
-
this
|
|
1175
|
+
this.#showList(this.listTarget, true);
|
|
1176
|
+
this.#open = true;
|
|
1177
|
+
this.#openList = this.listTarget;
|
|
1178
|
+
this.#expanded.write(this.inputTarget, "true");
|
|
857
1179
|
if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
|
|
858
1180
|
this.#reflectEmpty();
|
|
859
1181
|
}
|
|
860
|
-
/**
|
|
861
|
-
* Closes the list and clears the active option.
|
|
862
|
-
*
|
|
863
|
-
* Survives a missing input in both directions. `connect()` calls this second,
|
|
864
|
-
* so dereferencing the input here would throw before the chips, the roving
|
|
865
|
-
* seed, the chip listeners, the hidden fields and the outside-click listener —
|
|
866
|
-
* and Stimulus keeps the controller alive after that throw, so none of them
|
|
867
|
-
* would ever run and the selection would silently stop submitting. An input
|
|
868
|
-
* removed while the list is open must still let it come down *and* forget its
|
|
869
|
-
* active option, so only the `aria-expanded` write is guarded.
|
|
870
|
-
*/
|
|
871
1182
|
close() {
|
|
872
|
-
|
|
1183
|
+
this.#open = false;
|
|
1184
|
+
if (this.hasListTarget) {
|
|
1185
|
+
this.#showList(this.listTarget, false);
|
|
1186
|
+
this.#openList = this.listTarget;
|
|
1187
|
+
}
|
|
873
1188
|
this.#setActive(null);
|
|
874
|
-
if (this.hasInputTarget) this.inputTarget
|
|
1189
|
+
if (this.hasInputTarget) this.#expanded.write(this.inputTarget, "false");
|
|
875
1190
|
this.#reflectEmpty();
|
|
876
1191
|
}
|
|
877
|
-
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
878
1192
|
onKeydown(event) {
|
|
879
1193
|
if (event.defaultPrevented) return;
|
|
880
1194
|
if (isReservedArrowChord(event)) return;
|
|
@@ -939,39 +1253,20 @@ var MultiSelectController = class extends Controller {
|
|
|
939
1253
|
break;
|
|
940
1254
|
}
|
|
941
1255
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
* otherwise go dead until the user clicks back in ("focus stays on the input").
|
|
948
|
-
*/
|
|
949
|
-
toggleOption(event) {
|
|
950
|
-
const option = event.currentTarget.closest('[role="option"]');
|
|
1256
|
+
toggleOption(source) {
|
|
1257
|
+
const { event, host, origin, reason } = actionSource(source);
|
|
1258
|
+
if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
|
|
1259
|
+
return;
|
|
1260
|
+
const option = host?.closest(targetSelector(this.identifier, "option"));
|
|
951
1261
|
if (!option || !this.optionTargets.includes(option)) return;
|
|
952
|
-
this
|
|
953
|
-
this.#
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
*
|
|
958
|
-
* All three callers run *after* an option or a chip already took focus, and all
|
|
959
|
-
* three are reachable without an input — options and chips carry their own
|
|
960
|
-
* `data-action`. Throwing here would leave the chip removed but focus stranded
|
|
961
|
-
* on a detached button.
|
|
962
|
-
*/
|
|
1262
|
+
const focus = event !== null || this.element.contains(document.activeElement);
|
|
1263
|
+
if (!this.#toggleSelection(option, reason)) return;
|
|
1264
|
+
if (focus && (event !== null || this.element.contains(document.activeElement)))
|
|
1265
|
+
this.#focusInput();
|
|
1266
|
+
}
|
|
963
1267
|
#focusInput() {
|
|
964
1268
|
if (this.hasInputTarget) this.inputTarget.focus();
|
|
965
1269
|
}
|
|
966
|
-
/**
|
|
967
|
-
* Re-homes focus after the last chip was removed: to the input, else the root.
|
|
968
|
-
*
|
|
969
|
-
* Unlike the other {@link #focusInput} callers, the element that held focus has
|
|
970
|
-
* just left the DOM, so "leave it alone" is not an option — the browser already
|
|
971
|
-
* dropped it to `<body>`. The root borrows a `tabindex="-1"` just-in-time (not a
|
|
972
|
-
* Tab stop, handed back before Turbo caches or on teardown). Focus that landed
|
|
973
|
-
* on a real element is left alone, so a chip removed out of band never steals it.
|
|
974
|
-
*/
|
|
975
1270
|
#focusAfterLastTag() {
|
|
976
1271
|
if (this.hasInputTarget) {
|
|
977
1272
|
this.inputTarget.focus();
|
|
@@ -985,15 +1280,9 @@ var MultiSelectController = class extends Controller {
|
|
|
985
1280
|
this.#tabindex.lend(this.element);
|
|
986
1281
|
this.element.focus();
|
|
987
1282
|
}
|
|
988
|
-
/**
|
|
989
|
-
* Returns the borrowed tab stop. Owning the borrow is not enough — the value
|
|
990
|
-
* has to still be the one this instance wrote, since a consumer that changed it
|
|
991
|
-
* afterwards owns it now.
|
|
992
|
-
*/
|
|
993
1283
|
#releaseTabindex() {
|
|
994
1284
|
this.#tabindex.returnAll();
|
|
995
1285
|
}
|
|
996
|
-
/** Moves the active option by `delta` among visible options, wrapping. */
|
|
997
1286
|
#moveActive(delta) {
|
|
998
1287
|
const visible = this.#visibleOptions;
|
|
999
1288
|
const active = this.#activeOption;
|
|
@@ -1002,41 +1291,27 @@ var MultiSelectController = class extends Controller {
|
|
|
1002
1291
|
const next = (candidate + visible.length) % visible.length;
|
|
1003
1292
|
this.#setActive(visible[next] ?? null);
|
|
1004
1293
|
}
|
|
1005
|
-
|
|
1006
|
-
#toggleSelection(option) {
|
|
1294
|
+
#toggleSelection(option, reason = "user") {
|
|
1007
1295
|
const selected = option.getAttribute("aria-selected") === "true";
|
|
1008
1296
|
const limit = this.#selectionLimit;
|
|
1009
1297
|
if (!selected && limit > 0 && this.#selectedOptions.length >= limit) {
|
|
1010
|
-
return;
|
|
1298
|
+
return true;
|
|
1011
1299
|
}
|
|
1012
1300
|
if (selected) {
|
|
1013
1301
|
option.setAttribute("aria-selected", "false");
|
|
1014
1302
|
this.#removeTagFor(option);
|
|
1015
1303
|
} else {
|
|
1016
|
-
if (this.hasTagTemplateTarget && !this.#appendTag(option)) return;
|
|
1304
|
+
if (this.hasTagTemplateTarget && !this.#appendTag(option)) return true;
|
|
1017
1305
|
option.setAttribute("aria-selected", "true");
|
|
1018
1306
|
}
|
|
1019
1307
|
this.#refreshRoving();
|
|
1020
|
-
this.#
|
|
1021
|
-
const values = this.#values;
|
|
1022
|
-
this.#selectionValues = values;
|
|
1023
|
-
this.#announceTransition(
|
|
1308
|
+
return this.#publishSelection(
|
|
1024
1309
|
!selected,
|
|
1025
1310
|
this.#optionLabel(option),
|
|
1026
1311
|
this.#optionValue(option),
|
|
1027
|
-
|
|
1312
|
+
reason
|
|
1028
1313
|
);
|
|
1029
|
-
|
|
1030
|
-
}
|
|
1031
|
-
/**
|
|
1032
|
-
* Re-reads each chip's label from its option, in place.
|
|
1033
|
-
*
|
|
1034
|
-
* The value order alone does not say the chips are still correct: a server can
|
|
1035
|
-
* re-render the same candidate with a new label ("Apple" → "Green Apple"), and
|
|
1036
|
-
* the chip text and its `Remove {label}` name are both derived from the option.
|
|
1037
|
-
* Updating them here keeps the rebuild — which would drop focus — for the case
|
|
1038
|
-
* that actually needs it, a changed selection.
|
|
1039
|
-
*/
|
|
1314
|
+
}
|
|
1040
1315
|
#refreshTagLabels(selected) {
|
|
1041
1316
|
const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
|
|
1042
1317
|
for (const tag of this.tagTargets) {
|
|
@@ -1050,7 +1325,6 @@ var MultiSelectController = class extends Controller {
|
|
|
1050
1325
|
if (button.getAttribute("aria-label") !== name) button.setAttribute("aria-label", name);
|
|
1051
1326
|
}
|
|
1052
1327
|
}
|
|
1053
|
-
/** Builds one chip from the template for `option`. */
|
|
1054
1328
|
#appendTag(option) {
|
|
1055
1329
|
if (!this.hasTagsTarget) {
|
|
1056
1330
|
this.#rows.report('a "tags" target to append the chip to');
|
|
@@ -1061,14 +1335,6 @@ var MultiSelectController = class extends Controller {
|
|
|
1061
1335
|
this.tagsTarget.appendChild(chip);
|
|
1062
1336
|
return true;
|
|
1063
1337
|
}
|
|
1064
|
-
/**
|
|
1065
|
-
* Builds one fully named chip without mutating the live tag row, or `null`
|
|
1066
|
-
* when the authored template cannot produce one — in which case the selection
|
|
1067
|
-
* stays untouched and the row names the missing part once per connection.
|
|
1068
|
-
* Both callers establish the template first: a field authored without
|
|
1069
|
-
* `tagTemplate` renders no chips at all — a supported configuration — and
|
|
1070
|
-
* never reaches here.
|
|
1071
|
-
*/
|
|
1072
1338
|
#buildTag(option) {
|
|
1073
1339
|
const text = this.#optionLabel(option);
|
|
1074
1340
|
const value = this.#optionValue(option);
|
|
@@ -1079,19 +1345,16 @@ var MultiSelectController = class extends Controller {
|
|
|
1079
1345
|
row.button.tabIndex = -1;
|
|
1080
1346
|
return row.root;
|
|
1081
1347
|
}
|
|
1082
|
-
/** Expands the current template's localized remove-button name. */
|
|
1083
1348
|
#removeName(label, value) {
|
|
1084
1349
|
if (!this.hasTagTemplateTarget) return null;
|
|
1085
1350
|
const template = this.tagTemplateTarget.content.querySelector(`button${this.#rows.selector("remove")}`)?.getAttribute("aria-label")?.trim();
|
|
1086
1351
|
return template ? fillTemplate(template, { label, value }) : null;
|
|
1087
1352
|
}
|
|
1088
|
-
/** Removes the chip mirroring `option`, if present. */
|
|
1089
1353
|
#removeTagFor(option) {
|
|
1090
1354
|
const value = this.#optionValue(option);
|
|
1091
1355
|
const tag = this.tagTargets.find((candidate) => candidate.dataset.value === value);
|
|
1092
1356
|
tag?.remove();
|
|
1093
1357
|
}
|
|
1094
|
-
/** Removes chip `index` and deselects its option, re-homing focus. */
|
|
1095
1358
|
#removeTagAt(index, focus = "neighbor") {
|
|
1096
1359
|
const tag = this.tagTargets[index];
|
|
1097
1360
|
if (!tag) return;
|
|
@@ -1100,31 +1363,13 @@ var MultiSelectController = class extends Controller {
|
|
|
1100
1363
|
if (option) option.setAttribute("aria-selected", "false");
|
|
1101
1364
|
tag.remove();
|
|
1102
1365
|
this.#refreshRoving();
|
|
1103
|
-
this.#
|
|
1104
|
-
const values = this.#values;
|
|
1105
|
-
this.#selectionValues = values;
|
|
1106
|
-
this.#announceTransition(
|
|
1107
|
-
false,
|
|
1108
|
-
option ? this.#optionLabel(option) : value,
|
|
1109
|
-
value,
|
|
1110
|
-
values.length
|
|
1111
|
-
);
|
|
1112
|
-
this.dispatch("change", { detail: { values } });
|
|
1366
|
+
if (!this.#publishSelection(false, option ? this.#optionLabel(option) : value, value)) return;
|
|
1113
1367
|
if (focus === "input") {
|
|
1114
1368
|
this.#focusInput();
|
|
1115
1369
|
return;
|
|
1116
1370
|
}
|
|
1117
1371
|
if (!this.#chipRow.focusAfterRemoval(index)) this.#focusAfterLastTag();
|
|
1118
1372
|
}
|
|
1119
|
-
/**
|
|
1120
|
-
* Marks `option` active via `data-active` and the input's
|
|
1121
|
-
* `aria-activedescendant` (the attribute is removed, not emptied, when null).
|
|
1122
|
-
*
|
|
1123
|
-
* The state half runs even with no input, so a `close()` that cannot touch ARIA
|
|
1124
|
-
* still clears it: {@link MultiSelectController.open | open} seeds an active
|
|
1125
|
-
* option only when there is none, so a stale one makes the next open skip the
|
|
1126
|
-
* seeding and a replacement input gets no `aria-activedescendant` at all.
|
|
1127
|
-
*/
|
|
1128
1373
|
#setActive(option) {
|
|
1129
1374
|
const activeId = option ? ensureId(option, "stimeo-ms-opt") : null;
|
|
1130
1375
|
this.#activeOptionId = activeId;
|
|
@@ -1132,14 +1377,8 @@ var MultiSelectController = class extends Controller {
|
|
|
1132
1377
|
candidate.toggleAttribute("data-active", candidate === option);
|
|
1133
1378
|
}
|
|
1134
1379
|
if (option && this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
|
|
1135
|
-
if (
|
|
1136
|
-
if (activeId !== null) {
|
|
1137
|
-
this.inputTarget.setAttribute("aria-activedescendant", activeId);
|
|
1138
|
-
} else {
|
|
1139
|
-
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
1140
|
-
}
|
|
1380
|
+
if (this.hasInputTarget) this.#activeDescendant.write(this.inputTarget, activeId);
|
|
1141
1381
|
}
|
|
1142
|
-
/** Repairs only active identity before a key; the fallback waits for the target callback. */
|
|
1143
1382
|
#reconcileActiveForInteraction() {
|
|
1144
1383
|
const activeId = this.#activeOptionId;
|
|
1145
1384
|
if (activeId === null) return;
|
|
@@ -1151,88 +1390,84 @@ var MultiSelectController = class extends Controller {
|
|
|
1151
1390
|
this.#setActive(active);
|
|
1152
1391
|
}
|
|
1153
1392
|
}
|
|
1154
|
-
/**
|
|
1155
|
-
* Reflects whether the open list currently has no visible option targets, on
|
|
1156
|
-
* the root's state attribute and on the regions declared for that state.
|
|
1157
|
-
*/
|
|
1158
1393
|
#reflectEmpty() {
|
|
1159
1394
|
const empty = !this.#isClosed && this.#visibleOptions.length === 0;
|
|
1160
1395
|
this.element.toggleAttribute(`data-${this.identifier}-empty`, empty);
|
|
1161
1396
|
this.#emptyRegion.reflect(this.element, empty);
|
|
1162
1397
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
* target so the selection submits with a normal form (parity with tags-input).
|
|
1166
|
-
* No-ops without a `fields` target. When the
|
|
1167
|
-
* `form` value is set, each input gets a matching `form` attribute so the picker
|
|
1168
|
-
* can submit with a `<form>` it lives outside of.
|
|
1169
|
-
*/
|
|
1170
|
-
#syncFields(notify = false) {
|
|
1171
|
-
if (!this.hasFieldsTarget) return;
|
|
1398
|
+
#syncFields() {
|
|
1399
|
+
if (!this.hasFieldsTarget) return false;
|
|
1172
1400
|
const options = { name: this.nameValue, form: this.formValue };
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1401
|
+
return writeFields(this.fieldsTarget, this.#values, options);
|
|
1402
|
+
}
|
|
1403
|
+
#publishSelection(selected, label, value, reason = "user") {
|
|
1404
|
+
const values = this.#values;
|
|
1405
|
+
const changed = !this.#sameSelection(this.#selectionValues, values);
|
|
1406
|
+
const fieldChanged = this.#syncFields();
|
|
1407
|
+
this.#selectionValues = values;
|
|
1408
|
+
if (!changed) return true;
|
|
1409
|
+
const token = this.#moves.record();
|
|
1410
|
+
if (fieldChanged) commitField(this.fieldsTarget);
|
|
1411
|
+
if (!this.#moves.isLatest(token)) return false;
|
|
1412
|
+
this.#announceTransition(selected, label, value, values.length);
|
|
1413
|
+
if (!this.#moves.isLatest(token)) return false;
|
|
1414
|
+
this.dispatch("change", { detail: { values, reason } });
|
|
1415
|
+
return this.#moves.isLatest(token);
|
|
1176
1416
|
}
|
|
1177
|
-
/** Keeps exactly one chip remove button tabbable after the set changes. */
|
|
1178
1417
|
#refreshRoving() {
|
|
1179
1418
|
this.#chipRow.ensureTabStop();
|
|
1180
1419
|
}
|
|
1181
|
-
/**
|
|
1182
|
-
* Sends one localized selection transition through the page's shared announcer.
|
|
1183
|
-
*
|
|
1184
|
-
* @stimeoRuntimeOnly The texts word the one announcement of this change.
|
|
1185
|
-
*/
|
|
1186
1420
|
#announceTransition(selected, label, value, count) {
|
|
1187
1421
|
const template = selected ? this.announceTextValue : this.announceRemovedTextValue;
|
|
1188
1422
|
announce(fillTemplate(template, { label, value, count }));
|
|
1189
1423
|
}
|
|
1190
|
-
/** Whether two arrays represent the same selection set, independent of DOM order. */
|
|
1191
1424
|
#sameSelection(left, right) {
|
|
1192
1425
|
if (left.length !== right.length) return false;
|
|
1193
1426
|
const a = [...left].sort();
|
|
1194
1427
|
const b = [...right].sort();
|
|
1195
1428
|
return a.every((value, index) => value === b[index]);
|
|
1196
1429
|
}
|
|
1197
|
-
/** Closes the list on a click outside the controller element. */
|
|
1198
1430
|
#onOutsideClick = (event) => {
|
|
1199
1431
|
if (!this.#isClosed && !this.element.contains(event.target)) this.close();
|
|
1200
1432
|
};
|
|
1201
|
-
/** Trimmed visible label of an option. */
|
|
1202
1433
|
#optionLabel(option) {
|
|
1203
1434
|
return (option.textContent ?? "").trim();
|
|
1204
1435
|
}
|
|
1205
|
-
/** An option's stable value: its `data-value`, else its display label. */
|
|
1206
1436
|
#optionValue(option) {
|
|
1207
1437
|
return option.dataset.value ?? this.#optionLabel(option);
|
|
1208
1438
|
}
|
|
1209
|
-
/** Options not hidden by the current filter. */
|
|
1210
1439
|
get #visibleOptions() {
|
|
1211
1440
|
return this.optionTargets.filter((option) => !option.hidden);
|
|
1212
1441
|
}
|
|
1213
|
-
/** Current active target resolved by stable id, never a detached node reference. */
|
|
1214
1442
|
get #activeOption() {
|
|
1215
1443
|
return this.optionTargets.find((option) => option.id === this.#activeOptionId) ?? null;
|
|
1216
1444
|
}
|
|
1217
|
-
/** Options currently selected. */
|
|
1218
1445
|
get #selectedOptions() {
|
|
1219
1446
|
return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");
|
|
1220
1447
|
}
|
|
1221
|
-
/** Selected values in option order. */
|
|
1222
1448
|
get #values() {
|
|
1223
1449
|
return this.#selectedOptions.map((option) => this.#optionValue(option));
|
|
1224
1450
|
}
|
|
1225
|
-
/** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
|
|
1226
1451
|
get #selectionLimit() {
|
|
1227
|
-
if (
|
|
1228
|
-
return Math.max(1, Math.floor(this
|
|
1452
|
+
if (this.#safeMax <= 0) return 0;
|
|
1453
|
+
return Math.max(1, Math.floor(this.#safeMax));
|
|
1454
|
+
}
|
|
1455
|
+
#showList(list, open) {
|
|
1456
|
+
this.#listHidden.write(list, open ? null : "");
|
|
1229
1457
|
}
|
|
1230
|
-
/** Whether the list is currently hidden. */
|
|
1231
1458
|
get #isClosed() {
|
|
1232
1459
|
return !this.hasListTarget || this.listTarget.hidden !== false;
|
|
1233
1460
|
}
|
|
1461
|
+
get #safeMax() {
|
|
1462
|
+
return this.#numbers.read(
|
|
1463
|
+
this,
|
|
1464
|
+
"max",
|
|
1465
|
+
this.maxValue,
|
|
1466
|
+
_MultiSelectController.values.max.default,
|
|
1467
|
+
_MultiSelectController.valueConstraints.max
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1234
1470
|
};
|
|
1235
1471
|
|
|
1236
1472
|
export { MultiSelectController };
|
|
1237
|
-
//# sourceMappingURL=multi_select_controller.js.map
|
|
1238
1473
|
//# sourceMappingURL=multi_select_controller.js.map
|