stimeo-ui 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +184 -0
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +195 -40
- data/dist/controllers/breadcrumb_controller.js +5 -1
- data/dist/controllers/carousel_controller.js +90 -10
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/clipboard_controller.js +8 -3
- data/dist/controllers/collapsible_controller.js +4 -1
- data/dist/controllers/color_picker_controller.js +41 -11
- data/dist/controllers/context_menu_controller.js +2 -2
- data/dist/controllers/countdown_controller.js +5 -1
- data/dist/controllers/date_range_picker_controller.js +162 -31
- data/dist/controllers/direct_upload_controller.js +3 -3
- data/dist/controllers/empty_state_controller.js +107 -16
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/flash_controller.js +161 -21
- data/dist/controllers/form_validation_controller.js +8 -2
- data/dist/controllers/frame_loading_controller.js +94 -22
- data/dist/controllers/highlight_controller.js +38 -1
- data/dist/controllers/idle_controller.js +39 -6
- data/dist/controllers/local_time_controller.js +2 -0
- data/dist/controllers/masonry_controller.js +1 -1
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/meter_controller.js +3 -1
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/network_status_controller.js +1 -3
- data/dist/controllers/number_input_controller.js +455 -64
- data/dist/controllers/overflow_menu_controller.js +5 -1
- data/dist/controllers/pagination_controller.js +38 -1
- data/dist/controllers/password_strength_controller.js +21 -3
- data/dist/controllers/persist_controller.js +24 -5
- data/dist/controllers/pointer_drag_controller.js +10 -0
- data/dist/controllers/portal_controller.js +10 -0
- data/dist/controllers/progress_controller.js +7 -3
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/range_slider_controller.js +385 -94
- data/dist/controllers/rating_controller.js +274 -89
- data/dist/controllers/relative_time_controller.js +2 -0
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +155 -23
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/separator_controller.js +13 -17
- data/dist/controllers/skeleton_controller.js +71 -3
- data/dist/controllers/slider_controller.js +325 -48
- data/dist/controllers/spinner_controller.js +18 -3
- data/dist/controllers/step_indicator_controller.js +3 -1
- data/dist/controllers/stepper_controller.js +2 -0
- data/dist/controllers/switch_controller.js +162 -18
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/textarea_autosize_controller.js +1 -1
- data/dist/controllers/time_picker_controller.js +296 -104
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +24 -4
- data/dist/index.js +3811 -1048
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/multi_select_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
// src/utils/aria_ids.ts
|
|
6
23
|
var counter = 0;
|
|
7
24
|
function uniqueId(prefix = "stimeo") {
|
|
@@ -35,6 +52,177 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
35
52
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
36
53
|
}
|
|
37
54
|
|
|
55
|
+
// src/utils/roving_tabindex.ts
|
|
56
|
+
var RovingTabindex = class {
|
|
57
|
+
/** Returns the current ordered item elements; called on every operation. */
|
|
58
|
+
#getItems;
|
|
59
|
+
/**
|
|
60
|
+
* @param getItems - Returns the current ordered item elements. Called on every
|
|
61
|
+
* operation so the live target list is always used.
|
|
62
|
+
*/
|
|
63
|
+
constructor(getItems) {
|
|
64
|
+
this.#getItems = getItems;
|
|
65
|
+
}
|
|
66
|
+
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
67
|
+
get activeIndex() {
|
|
68
|
+
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
72
|
+
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
73
|
+
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
74
|
+
* "nothing is currently tabbable".
|
|
75
|
+
*
|
|
76
|
+
* @param index - Position of the item to make tabbable.
|
|
77
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
78
|
+
* and `items` to reuse an event-scoped collection snapshot.
|
|
79
|
+
*/
|
|
80
|
+
setActive(index, options = {}) {
|
|
81
|
+
const { focus = false } = options;
|
|
82
|
+
const items = options.items ?? this.#getItems();
|
|
83
|
+
items.forEach((item, i) => {
|
|
84
|
+
item.tabIndex = i === index ? 0 : -1;
|
|
85
|
+
});
|
|
86
|
+
if (focus) items[index]?.focus();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/utils/chip_row.ts
|
|
91
|
+
var ChipRow = class {
|
|
92
|
+
#directionElement;
|
|
93
|
+
#getItems;
|
|
94
|
+
#getButton;
|
|
95
|
+
#onRemove;
|
|
96
|
+
#focusAfterEnd;
|
|
97
|
+
#roving = new RovingTabindex(() => this.buttons);
|
|
98
|
+
#container = null;
|
|
99
|
+
constructor(options) {
|
|
100
|
+
this.#directionElement = options.directionElement;
|
|
101
|
+
this.#getItems = options.getItems;
|
|
102
|
+
this.#getButton = options.getButton ?? ((item) => item.querySelector("button"));
|
|
103
|
+
this.#onRemove = options.onRemove;
|
|
104
|
+
this.#focusAfterEnd = options.focusAfterEnd;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Binds delegation to `container`; any row currently bound is released first.
|
|
108
|
+
* Idempotent for the same node, so target callbacks may call it freely.
|
|
109
|
+
*/
|
|
110
|
+
connect(container) {
|
|
111
|
+
if (this.#container === container) return;
|
|
112
|
+
this.disconnect();
|
|
113
|
+
this.#container = container;
|
|
114
|
+
container.addEventListener("click", this.#onClick);
|
|
115
|
+
container.addEventListener("keydown", this.#onKeydown);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Releases the current row. When `container` is supplied, a stale disconnect
|
|
119
|
+
* callback cannot tear listeners off a newer replacement target.
|
|
120
|
+
*/
|
|
121
|
+
disconnect(container) {
|
|
122
|
+
const current = this.#container;
|
|
123
|
+
if (!current || container !== void 0 && current !== container) return;
|
|
124
|
+
current.removeEventListener("click", this.#onClick);
|
|
125
|
+
current.removeEventListener("keydown", this.#onKeydown);
|
|
126
|
+
this.#container = null;
|
|
127
|
+
}
|
|
128
|
+
/** Current remove buttons, at most one consumer-resolved button per chip item. */
|
|
129
|
+
get buttons() {
|
|
130
|
+
return this.#entries.map(({ button }) => button);
|
|
131
|
+
}
|
|
132
|
+
/** Number of currently navigable remove buttons. */
|
|
133
|
+
get length() {
|
|
134
|
+
return this.buttons.length;
|
|
135
|
+
}
|
|
136
|
+
/** Declared-item index of the last navigable chip, or `-1` for an empty row. */
|
|
137
|
+
get lastIndex() {
|
|
138
|
+
return this.#entries.at(-1)?.itemIndex ?? -1;
|
|
139
|
+
}
|
|
140
|
+
/** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
|
|
141
|
+
ensureTabStop() {
|
|
142
|
+
const buttons = this.#entries.map(({ button }) => button);
|
|
143
|
+
const active = buttons.findIndex((button) => button.tabIndex === 0);
|
|
144
|
+
const count = buttons.filter((button) => button.tabIndex === 0).length;
|
|
145
|
+
if (buttons.length > 0 && count !== 1) {
|
|
146
|
+
this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Focuses the last chip, returning false for an empty row. */
|
|
150
|
+
focusLast() {
|
|
151
|
+
const buttons = this.#entries.map(({ button }) => button);
|
|
152
|
+
const last = buttons.length - 1;
|
|
153
|
+
if (last < 0) return false;
|
|
154
|
+
this.#roving.setActive(last, { focus: true, items: buttons });
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Focuses the chip that followed a removed index, or the new last chip.
|
|
159
|
+
* Returns false when removal emptied the row so the consumer can rescue focus.
|
|
160
|
+
*/
|
|
161
|
+
focusAfterRemoval(index) {
|
|
162
|
+
const entries = this.#entries;
|
|
163
|
+
if (entries.length === 0) return false;
|
|
164
|
+
const following = entries.findIndex((entry) => entry.itemIndex >= index);
|
|
165
|
+
this.#roving.setActive(following === -1 ? entries.length - 1 : following, {
|
|
166
|
+
focus: true,
|
|
167
|
+
items: entries.map(({ button }) => button)
|
|
168
|
+
});
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
/** Current remove-button entries, retaining each button's declared item index. */
|
|
172
|
+
get #entries() {
|
|
173
|
+
return this.#getItems().flatMap((item, itemIndex) => {
|
|
174
|
+
const button = this.#getButton(item);
|
|
175
|
+
return button ? [{ button, itemIndex }] : [];
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/** Resolves a delegated event to the remove button owned by a declared chip. */
|
|
179
|
+
#entry(event) {
|
|
180
|
+
const target = event.target;
|
|
181
|
+
if (!(target instanceof Element)) return null;
|
|
182
|
+
const button = target.closest("button");
|
|
183
|
+
const entries = this.#entries;
|
|
184
|
+
const buttonIndex = entries.findIndex((entry2) => entry2.button === button);
|
|
185
|
+
if (buttonIndex === -1) return null;
|
|
186
|
+
const entry = entries[buttonIndex];
|
|
187
|
+
return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
|
|
188
|
+
}
|
|
189
|
+
/** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
|
|
190
|
+
#onClick = (event) => {
|
|
191
|
+
const entry = this.#entry(event);
|
|
192
|
+
if (!entry) return;
|
|
193
|
+
this.#onRemove(entry.itemIndex);
|
|
194
|
+
};
|
|
195
|
+
/** Applies the shared logical-arrow and removal policy within the chip row. */
|
|
196
|
+
#onKeydown = (event) => {
|
|
197
|
+
if (event.defaultPrevented || isReservedArrowChord(event)) return;
|
|
198
|
+
const entry = this.#entry(event);
|
|
199
|
+
if (!entry) return;
|
|
200
|
+
const buttons = entry.buttons;
|
|
201
|
+
const index = entry.buttonIndex;
|
|
202
|
+
switch (logicalArrowKey(event.key, this.#directionElement)) {
|
|
203
|
+
case "ArrowLeft":
|
|
204
|
+
if (index > 0) {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
this.#roving.setActive(index - 1, { focus: true, items: buttons });
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
case "ArrowRight":
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
if (index < buttons.length - 1) {
|
|
212
|
+
this.#roving.setActive(index + 1, { focus: true, items: buttons });
|
|
213
|
+
} else {
|
|
214
|
+
this.#focusAfterEnd();
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
case "Delete":
|
|
218
|
+
case "Backspace":
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
this.#onRemove(entry.itemIndex);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
38
226
|
// src/utils/composition_tracker.ts
|
|
39
227
|
var CompositionTracker = class {
|
|
40
228
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -127,36 +315,32 @@ function scrollOptionIntoView(list, option) {
|
|
|
127
315
|
}
|
|
128
316
|
}
|
|
129
317
|
|
|
130
|
-
// src/utils/
|
|
131
|
-
var
|
|
132
|
-
/**
|
|
133
|
-
#
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
318
|
+
// src/utils/before_cache_reset.ts
|
|
319
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
320
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
321
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
322
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
323
|
+
static #onBeforeCache = () => {
|
|
324
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
325
|
+
};
|
|
326
|
+
#rewind;
|
|
327
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
328
|
+
constructor(rewind) {
|
|
329
|
+
this.#rewind = rewind;
|
|
140
330
|
}
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
331
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
332
|
+
activate() {
|
|
333
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
334
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
335
|
+
if (first) {
|
|
336
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
337
|
+
}
|
|
144
338
|
}
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
* @param index - Position of the item to make tabbable.
|
|
152
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item.
|
|
153
|
-
*/
|
|
154
|
-
setActive(index, { focus = false } = {}) {
|
|
155
|
-
const items = this.#getItems();
|
|
156
|
-
items.forEach((item, i) => {
|
|
157
|
-
item.tabIndex = i === index ? 0 : -1;
|
|
158
|
-
});
|
|
159
|
-
if (focus) items[index]?.focus();
|
|
339
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
340
|
+
deactivate() {
|
|
341
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
342
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
343
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
160
344
|
}
|
|
161
345
|
};
|
|
162
346
|
|
|
@@ -164,6 +348,8 @@ var RovingTabindex = class {
|
|
|
164
348
|
var TabindexLoan = class {
|
|
165
349
|
#value;
|
|
166
350
|
#lent = /* @__PURE__ */ new Set();
|
|
351
|
+
/** Returns live loans before Turbo can copy them into its page snapshot. */
|
|
352
|
+
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
167
353
|
/**
|
|
168
354
|
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
169
355
|
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
@@ -177,6 +363,7 @@ var TabindexLoan = class {
|
|
|
177
363
|
if (element.hasAttribute("tabindex")) return;
|
|
178
364
|
element.setAttribute("tabindex", this.#value);
|
|
179
365
|
this.#lent.add(element);
|
|
366
|
+
this.#beforeCache.activate();
|
|
180
367
|
}
|
|
181
368
|
/** Takes back every loan whose value is still the one that was lent. */
|
|
182
369
|
returnAll() {
|
|
@@ -184,6 +371,7 @@ var TabindexLoan = class {
|
|
|
184
371
|
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
185
372
|
}
|
|
186
373
|
this.#lent.clear();
|
|
374
|
+
this.#beforeCache.deactivate();
|
|
187
375
|
}
|
|
188
376
|
};
|
|
189
377
|
|
|
@@ -196,21 +384,26 @@ var MultiSelectController = class extends Controller {
|
|
|
196
384
|
"tags",
|
|
197
385
|
"tag",
|
|
198
386
|
"tagTemplate",
|
|
199
|
-
"
|
|
387
|
+
"label",
|
|
388
|
+
"remove",
|
|
200
389
|
"fields"
|
|
201
390
|
];
|
|
202
391
|
static values = {
|
|
203
392
|
max: { type: Number, default: 0 },
|
|
204
393
|
name: { type: String, default: "options[]" },
|
|
205
|
-
form: { type: String, default: "" }
|
|
394
|
+
form: { type: String, default: "" },
|
|
395
|
+
announceText: { type: String, default: "" },
|
|
396
|
+
announceRemovedText: { type: String, default: "" }
|
|
206
397
|
};
|
|
207
398
|
static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
|
|
208
|
-
static events = ["change", "filter"];
|
|
399
|
+
static events = ["change", "filter", "reconcile"];
|
|
209
400
|
/** Stable id of the active option; the current target is resolved from the DOM. */
|
|
210
401
|
#activeOptionId = null;
|
|
211
402
|
/** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
|
|
212
403
|
#tabindex = new TabindexLoan();
|
|
213
|
-
/**
|
|
404
|
+
/** Last reconciled selection, used to distinguish state changes from derived-DOM repair. */
|
|
405
|
+
#selectionValues = [];
|
|
406
|
+
/** Whether initial normalization finished, so a fields callback cannot mirror stale state. */
|
|
214
407
|
#connected = false;
|
|
215
408
|
/** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
|
|
216
409
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
|
|
@@ -229,17 +422,26 @@ var MultiSelectController = class extends Controller {
|
|
|
229
422
|
this.filter();
|
|
230
423
|
}
|
|
231
424
|
});
|
|
232
|
-
|
|
425
|
+
/** Whether this connection already reported its unusable chip template. */
|
|
426
|
+
#warnedTemplate = false;
|
|
427
|
+
/** Shared delegated interaction for the replaceable row of removable chips. */
|
|
428
|
+
#chipRow = new ChipRow({
|
|
429
|
+
directionElement: this.element,
|
|
430
|
+
getItems: () => this.tagTargets,
|
|
431
|
+
getButton: (tag) => tag.querySelector('button[data-stimeo--multi-select-target~="remove"]'),
|
|
432
|
+
onRemove: (index) => this.#removeTagAt(index),
|
|
433
|
+
focusAfterEnd: () => this.#focusInput()
|
|
434
|
+
});
|
|
233
435
|
/** Starts closed, syncs chips for any pre-selected options, and listens out. */
|
|
234
436
|
connect() {
|
|
235
|
-
|
|
236
|
-
this.#normalizeSelection();
|
|
437
|
+
this.#warnedTemplate = false;
|
|
438
|
+
this.#normalizeSelection([]);
|
|
237
439
|
this.close();
|
|
238
440
|
if (this.hasTagsTarget) {
|
|
239
|
-
this.
|
|
240
|
-
this.tagsTarget.addEventListener("click", this.#onTagClick);
|
|
441
|
+
this.#chipRow.connect(this.tagsTarget);
|
|
241
442
|
this.#rebuildTags();
|
|
242
443
|
}
|
|
444
|
+
this.#selectionValues = this.#values;
|
|
243
445
|
this.#syncFields();
|
|
244
446
|
document.addEventListener("click", this.#onOutsideClick, true);
|
|
245
447
|
this.#connected = true;
|
|
@@ -251,10 +453,16 @@ var MultiSelectController = class extends Controller {
|
|
|
251
453
|
* cleared before deriving afresh to avoid duplicates.
|
|
252
454
|
*/
|
|
253
455
|
#rebuildTags() {
|
|
254
|
-
if (!this.hasTagsTarget) return;
|
|
456
|
+
if (!this.hasTagsTarget || !this.hasTagTemplateTarget) return;
|
|
457
|
+
const fragments = [];
|
|
458
|
+
for (const option of this.#selectedOptions) {
|
|
459
|
+
const fragment = this.#buildTag(option);
|
|
460
|
+
if (!fragment) return;
|
|
461
|
+
fragments.push(fragment);
|
|
462
|
+
}
|
|
255
463
|
for (const tag of this.tagTargets) tag.remove();
|
|
256
|
-
|
|
257
|
-
|
|
464
|
+
this.tagsTarget.append(...fragments);
|
|
465
|
+
this.#chipRow.ensureTabStop();
|
|
258
466
|
}
|
|
259
467
|
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
260
468
|
disconnect() {
|
|
@@ -262,10 +470,7 @@ var MultiSelectController = class extends Controller {
|
|
|
262
470
|
this.#reconcile.cancel();
|
|
263
471
|
this.#composition.disconnect();
|
|
264
472
|
this.#ignorePostCompositionInput = false;
|
|
265
|
-
|
|
266
|
-
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
267
|
-
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
268
|
-
}
|
|
473
|
+
this.#chipRow.disconnect();
|
|
269
474
|
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
270
475
|
this.#releaseTabindex();
|
|
271
476
|
}
|
|
@@ -275,10 +480,35 @@ var MultiSelectController = class extends Controller {
|
|
|
275
480
|
}
|
|
276
481
|
/** Cleans a removed target and reconciles active state against the surviving DOM. */
|
|
277
482
|
optionTargetDisconnected(option) {
|
|
278
|
-
if (!this.#connected) return;
|
|
279
483
|
option.removeAttribute("data-active");
|
|
280
484
|
this.#scheduleOptionReconcile();
|
|
281
485
|
}
|
|
486
|
+
/** Rebinds delegated chip interaction when Turbo replaces the tags container. */
|
|
487
|
+
tagsTargetConnected(tags) {
|
|
488
|
+
this.#chipRow.connect(tags);
|
|
489
|
+
this.#scheduleOptionReconcile();
|
|
490
|
+
}
|
|
491
|
+
/** Releases only the row that actually disconnected, never a newer replacement. */
|
|
492
|
+
tagsTargetDisconnected(tags) {
|
|
493
|
+
this.#chipRow.disconnect(tags);
|
|
494
|
+
this.#scheduleOptionReconcile();
|
|
495
|
+
}
|
|
496
|
+
/** Seeds a fields target inserted after connect from the current selection. */
|
|
497
|
+
fieldsTargetConnected() {
|
|
498
|
+
if (this.#connected) this.#syncFields();
|
|
499
|
+
}
|
|
500
|
+
/** Reconciles a runtime max change, including dropping any newly invalid overflow. */
|
|
501
|
+
maxValueChanged() {
|
|
502
|
+
this.#scheduleOptionReconcile();
|
|
503
|
+
}
|
|
504
|
+
/** Rebuilds submitted fields when their public name changes at runtime. */
|
|
505
|
+
nameValueChanged() {
|
|
506
|
+
this.#scheduleOptionReconcile();
|
|
507
|
+
}
|
|
508
|
+
/** Rebuilds submitted fields when their associated form changes at runtime. */
|
|
509
|
+
formValueChanged() {
|
|
510
|
+
this.#scheduleOptionReconcile();
|
|
511
|
+
}
|
|
282
512
|
/** Schedules one reconciliation after all callbacks in the mutation batch. */
|
|
283
513
|
#scheduleOptionReconcile() {
|
|
284
514
|
this.#reconcile.schedule();
|
|
@@ -301,7 +531,17 @@ var MultiSelectController = class extends Controller {
|
|
|
301
531
|
const next = this.#isClosed ? null : active && !active.hidden ? active : visible[0] ?? null;
|
|
302
532
|
this.#setActive(next);
|
|
303
533
|
this.#reflectEmpty();
|
|
304
|
-
this.#
|
|
534
|
+
const previous = this.#selectionValues;
|
|
535
|
+
const previousLabels = new Map(
|
|
536
|
+
this.tagTargets.map((tag) => [
|
|
537
|
+
tag.dataset.value ?? "",
|
|
538
|
+
(tag.querySelector('[data-stimeo--multi-select-target~="label"]')?.textContent ?? "").trim()
|
|
539
|
+
])
|
|
540
|
+
);
|
|
541
|
+
const priority = [
|
|
542
|
+
.../* @__PURE__ */ new Set([...this.tagTargets.map((tag) => tag.dataset.value ?? ""), ...previous])
|
|
543
|
+
];
|
|
544
|
+
this.#normalizeSelection(priority);
|
|
305
545
|
const selected = this.#selectedOptions;
|
|
306
546
|
const nextValues = selected.map((option) => this.#optionValue(option)).sort();
|
|
307
547
|
const tagValues = this.tagTargets.map((tag) => tag.dataset.value ?? "").sort();
|
|
@@ -309,19 +549,52 @@ var MultiSelectController = class extends Controller {
|
|
|
309
549
|
if (unchanged) this.#refreshTagLabels(selected);
|
|
310
550
|
else this.#rebuildTags();
|
|
311
551
|
this.#syncFields();
|
|
552
|
+
const values = this.#values;
|
|
553
|
+
const changed = !this.#sameSelection(previous, values);
|
|
554
|
+
this.#selectionValues = values;
|
|
555
|
+
if (!changed) return;
|
|
556
|
+
const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
|
|
557
|
+
for (const value of previous) {
|
|
558
|
+
if (!values.includes(value)) {
|
|
559
|
+
this.#announceTransition(false, previousLabels.get(value) || value, value, values.length);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
for (const value of values) {
|
|
563
|
+
if (previous.includes(value)) continue;
|
|
564
|
+
const option = options.get(value);
|
|
565
|
+
this.#announceTransition(true, this.#optionLabel(option), value, values.length);
|
|
566
|
+
}
|
|
567
|
+
this.dispatch("reconcile", { detail: { values } });
|
|
312
568
|
}
|
|
313
569
|
/**
|
|
314
|
-
* Gives every option an explicit `aria-selected
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* case here — the list is `aria-multiselectable` — so nothing is dropped.
|
|
570
|
+
* Gives every option an explicit `aria-selected` and enforces the current cap.
|
|
571
|
+
* Priority preserves existing chip order at runtime; a fresh connection passes
|
|
572
|
+
* no priority, so deterministic option DOM order chooses the initial survivors.
|
|
318
573
|
*/
|
|
319
|
-
#normalizeSelection() {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
574
|
+
#normalizeSelection(priority) {
|
|
575
|
+
const selected = this.optionTargets.filter(
|
|
576
|
+
(option) => option.getAttribute("aria-selected") === "true"
|
|
577
|
+
);
|
|
578
|
+
const limit = this.#selectionLimit;
|
|
579
|
+
const kept = /* @__PURE__ */ new Set();
|
|
580
|
+
if (limit === 0) {
|
|
581
|
+
for (const option of selected) kept.add(option);
|
|
582
|
+
} else {
|
|
583
|
+
for (const value of priority) {
|
|
584
|
+
if (kept.size >= limit) break;
|
|
585
|
+
const option = selected.find(
|
|
586
|
+
(candidate) => !kept.has(candidate) && this.#optionValue(candidate) === value
|
|
587
|
+
);
|
|
588
|
+
if (option) kept.add(option);
|
|
589
|
+
}
|
|
590
|
+
for (const option of selected) {
|
|
591
|
+
if (kept.size >= limit) break;
|
|
592
|
+
kept.add(option);
|
|
323
593
|
}
|
|
324
594
|
}
|
|
595
|
+
for (const option of this.optionTargets) {
|
|
596
|
+
option.setAttribute("aria-selected", String(kept.has(option)));
|
|
597
|
+
}
|
|
325
598
|
}
|
|
326
599
|
/**
|
|
327
600
|
* Tracks an input added initially or after connect, and makes it describe the
|
|
@@ -344,6 +617,7 @@ var MultiSelectController = class extends Controller {
|
|
|
344
617
|
}
|
|
345
618
|
/** Filters confirmed input text, opens, and re-seeds the active option. */
|
|
346
619
|
filter(event) {
|
|
620
|
+
if (!this.hasInputTarget) return;
|
|
347
621
|
if (event && this.#ignorePostCompositionInput) {
|
|
348
622
|
this.#ignorePostCompositionInput = false;
|
|
349
623
|
return;
|
|
@@ -368,10 +642,14 @@ var MultiSelectController = class extends Controller {
|
|
|
368
642
|
* opening is skipped entirely, where {@link close} still closes.
|
|
369
643
|
*/
|
|
370
644
|
open() {
|
|
371
|
-
if (!this.hasListTarget || !this.hasInputTarget)
|
|
645
|
+
if (!this.hasListTarget || !this.hasInputTarget) {
|
|
646
|
+
this.#reflectEmpty();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
372
649
|
this.listTarget.hidden = false;
|
|
373
650
|
this.inputTarget.setAttribute("aria-expanded", "true");
|
|
374
651
|
if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
|
|
652
|
+
this.#reflectEmpty();
|
|
375
653
|
}
|
|
376
654
|
/**
|
|
377
655
|
* Closes the list and clears the active option.
|
|
@@ -385,16 +663,16 @@ var MultiSelectController = class extends Controller {
|
|
|
385
663
|
* active option, so only the `aria-expanded` write is guarded.
|
|
386
664
|
*/
|
|
387
665
|
close() {
|
|
388
|
-
if (
|
|
389
|
-
this.listTarget.hidden = true;
|
|
666
|
+
if (this.hasListTarget) this.listTarget.hidden = true;
|
|
390
667
|
this.#setActive(null);
|
|
391
|
-
if (
|
|
392
|
-
this
|
|
668
|
+
if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
|
|
669
|
+
this.#reflectEmpty();
|
|
393
670
|
}
|
|
394
671
|
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
395
672
|
onKeydown(event) {
|
|
396
673
|
if (event.defaultPrevented) return;
|
|
397
674
|
if (isReservedArrowChord(event)) return;
|
|
675
|
+
if (!this.hasInputTarget) return;
|
|
398
676
|
if (this.#composition.isComposing(event)) return;
|
|
399
677
|
this.#reconcileActiveForInteraction();
|
|
400
678
|
switch (logicalArrowKey(event.key, this.element)) {
|
|
@@ -437,17 +715,17 @@ var MultiSelectController = class extends Controller {
|
|
|
437
715
|
break;
|
|
438
716
|
case "Backspace":
|
|
439
717
|
if (this.inputTarget.value === "") {
|
|
440
|
-
const
|
|
441
|
-
if (
|
|
718
|
+
const last = this.#chipRow.lastIndex;
|
|
719
|
+
if (last >= 0) {
|
|
442
720
|
event.preventDefault();
|
|
443
|
-
this.#removeTagAt(
|
|
721
|
+
this.#removeTagAt(last, "input");
|
|
444
722
|
}
|
|
445
723
|
}
|
|
446
724
|
break;
|
|
447
725
|
case "ArrowLeft":
|
|
448
|
-
if (this.inputTarget.value === "" && this.#
|
|
726
|
+
if (this.inputTarget.value === "" && this.#chipRow.length > 0) {
|
|
449
727
|
event.preventDefault();
|
|
450
|
-
this.#
|
|
728
|
+
this.#chipRow.focusLast();
|
|
451
729
|
}
|
|
452
730
|
break;
|
|
453
731
|
case "Tab":
|
|
@@ -485,8 +763,8 @@ var MultiSelectController = class extends Controller {
|
|
|
485
763
|
* Unlike the other {@link #focusInput} callers, the element that held focus has
|
|
486
764
|
* just left the DOM, so "leave it alone" is not an option — the browser already
|
|
487
765
|
* dropped it to `<body>`. The root borrows a `tabindex="-1"` just-in-time (not a
|
|
488
|
-
* Tab stop, handed back on teardown). Focus that landed
|
|
489
|
-
* left alone, so a chip removed out of band never steals it.
|
|
766
|
+
* Tab stop, handed back before Turbo caches or on teardown). Focus that landed
|
|
767
|
+
* on a real element is left alone, so a chip removed out of band never steals it.
|
|
490
768
|
*/
|
|
491
769
|
#focusAfterLastTag() {
|
|
492
770
|
if (this.hasInputTarget) {
|
|
@@ -509,43 +787,40 @@ var MultiSelectController = class extends Controller {
|
|
|
509
787
|
#releaseTabindex() {
|
|
510
788
|
this.#tabindex.returnAll();
|
|
511
789
|
}
|
|
512
|
-
/**
|
|
513
|
-
* Removes the chip whose remove button was clicked, deselecting its option.
|
|
514
|
-
* Delegated on the tags container (like `#onTagKeydown`) rather than bound
|
|
515
|
-
* per chip via `data-action`, so it works the instant a chip is appended without
|
|
516
|
-
* waiting on Stimulus to wire a freshly created element.
|
|
517
|
-
*/
|
|
518
|
-
#onTagClick = (event) => {
|
|
519
|
-
const button = event.target.closest("button");
|
|
520
|
-
if (!button || !this.tagsTarget.contains(button)) return;
|
|
521
|
-
const index = this.#removeButtons.indexOf(button);
|
|
522
|
-
if (index !== -1) this.#removeTagAt(index);
|
|
523
|
-
};
|
|
524
790
|
/** Moves the active option by `delta` among visible options, wrapping. */
|
|
525
791
|
#moveActive(delta) {
|
|
526
792
|
const visible = this.#visibleOptions;
|
|
527
|
-
|
|
528
|
-
const current =
|
|
793
|
+
const active = this.#activeOption;
|
|
794
|
+
const current = visible.indexOf(active);
|
|
529
795
|
const candidate = current === -1 ? delta > 0 ? 0 : visible.length - 1 : current + delta;
|
|
530
796
|
const next = (candidate + visible.length) % visible.length;
|
|
531
797
|
this.#setActive(visible[next] ?? null);
|
|
532
798
|
}
|
|
533
|
-
/** Selects/deselects `option`, honoring `max`, and syncs chip +
|
|
799
|
+
/** Selects/deselects `option`, honoring `max`, and syncs chip + announcement. */
|
|
534
800
|
#toggleSelection(option) {
|
|
535
801
|
const selected = option.getAttribute("aria-selected") === "true";
|
|
536
|
-
|
|
802
|
+
const limit = this.#selectionLimit;
|
|
803
|
+
if (!selected && limit > 0 && this.#selectedOptions.length >= limit) {
|
|
537
804
|
return;
|
|
538
805
|
}
|
|
539
|
-
option.setAttribute("aria-selected", String(!selected));
|
|
540
806
|
if (selected) {
|
|
807
|
+
option.setAttribute("aria-selected", "false");
|
|
541
808
|
this.#removeTagFor(option);
|
|
542
809
|
} else {
|
|
543
|
-
this.#appendTag(option);
|
|
810
|
+
if (this.hasTagTemplateTarget && !this.#appendTag(option)) return;
|
|
811
|
+
option.setAttribute("aria-selected", "true");
|
|
544
812
|
}
|
|
545
|
-
this.#announce(this.#optionLabel(option));
|
|
546
813
|
this.#refreshRoving();
|
|
547
814
|
this.#syncFields();
|
|
548
|
-
|
|
815
|
+
const values = this.#values;
|
|
816
|
+
this.#selectionValues = values;
|
|
817
|
+
this.#announceTransition(
|
|
818
|
+
!selected,
|
|
819
|
+
this.#optionLabel(option),
|
|
820
|
+
this.#optionValue(option),
|
|
821
|
+
values.length
|
|
822
|
+
);
|
|
823
|
+
this.dispatch("change", { detail: { values } });
|
|
549
824
|
}
|
|
550
825
|
/**
|
|
551
826
|
* Re-reads each chip's label from its option, in place.
|
|
@@ -560,31 +835,81 @@ var MultiSelectController = class extends Controller {
|
|
|
560
835
|
const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
|
|
561
836
|
for (const tag of this.tagTargets) {
|
|
562
837
|
const option = options.get(tag.dataset.value ?? "");
|
|
563
|
-
if (!option) continue;
|
|
564
838
|
const text = this.#optionLabel(option);
|
|
565
|
-
const label = tag.querySelector('[data-multi-select-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
839
|
+
const label = tag.querySelector('[data-stimeo--multi-select-target~="label"]');
|
|
840
|
+
const button = tag.querySelector(
|
|
841
|
+
'button[data-stimeo--multi-select-target~="remove"]'
|
|
842
|
+
);
|
|
843
|
+
const name = this.#removeName(text, this.#optionValue(option));
|
|
844
|
+
if (!label || !button || !name) continue;
|
|
845
|
+
if (label.textContent !== text) label.textContent = text;
|
|
846
|
+
if (button.getAttribute("aria-label") !== name) button.setAttribute("aria-label", name);
|
|
572
847
|
}
|
|
573
848
|
}
|
|
574
849
|
/** Builds one chip from the template for `option`. */
|
|
575
850
|
#appendTag(option) {
|
|
576
|
-
if (!this.
|
|
851
|
+
if (!this.hasTagsTarget) {
|
|
852
|
+
this.#warnTemplate('a "tags" target to append the chip to');
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
const fragment = this.#buildTag(option);
|
|
856
|
+
if (!fragment) return false;
|
|
857
|
+
this.tagsTarget.appendChild(fragment);
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Builds one fully named chip without mutating the live tag row, or `null`
|
|
862
|
+
* when the authored template cannot produce one. Both callers establish the
|
|
863
|
+
* template first: a field authored without `tagTemplate` renders no chips at
|
|
864
|
+
* all — a supported configuration — and never reaches here.
|
|
865
|
+
*/
|
|
866
|
+
#buildTag(option) {
|
|
577
867
|
const fragment = this.tagTemplateTarget.content.cloneNode(true);
|
|
578
|
-
const tag = fragment.querySelector('[data-stimeo--multi-select-target
|
|
579
|
-
const label = fragment.querySelector(
|
|
580
|
-
|
|
581
|
-
|
|
868
|
+
const tag = fragment.querySelector('[data-stimeo--multi-select-target~="tag"]');
|
|
869
|
+
const label = fragment.querySelector(
|
|
870
|
+
'[data-stimeo--multi-select-target~="label"]'
|
|
871
|
+
);
|
|
872
|
+
const button = fragment.querySelector(
|
|
873
|
+
'button[data-stimeo--multi-select-target~="remove"]'
|
|
874
|
+
);
|
|
582
875
|
const text = this.#optionLabel(option);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
876
|
+
const value = this.#optionValue(option);
|
|
877
|
+
const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
|
|
878
|
+
if (!tag) return this.#warnTemplate('a "tag" target');
|
|
879
|
+
if (!label) return this.#warnTemplate('a "label" target');
|
|
880
|
+
if (!button) return this.#warnTemplate('a "remove" target <button>');
|
|
881
|
+
if (removeName === "") {
|
|
882
|
+
return this.#warnTemplate('a non-empty aria-label on its "remove" target');
|
|
883
|
+
}
|
|
884
|
+
tag.dataset.value = value;
|
|
885
|
+
label.textContent = text;
|
|
886
|
+
button.setAttribute("aria-label", fillTemplate(removeName, { label: text, value }));
|
|
586
887
|
button.tabIndex = -1;
|
|
587
|
-
|
|
888
|
+
return fragment;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Reports an unusable chip template to the author, once per connection.
|
|
892
|
+
*
|
|
893
|
+
* The selection itself stays untouched — no `aria-selected`, chip, hidden
|
|
894
|
+
* field, announcement, or event moves. Without this line the only symptom is
|
|
895
|
+
* a listbox whose options refuse to select, and the two causes the Inspector
|
|
896
|
+
* cannot see statically (a name that renders empty from a missing
|
|
897
|
+
* translation, a server-rendered template) would have no diagnostic anywhere.
|
|
898
|
+
*/
|
|
899
|
+
#warnTemplate(missing) {
|
|
900
|
+
if (!this.#warnedTemplate) {
|
|
901
|
+
this.#warnedTemplate = true;
|
|
902
|
+
console.warn(
|
|
903
|
+
`Stimeo UI: "${this.identifier}" changed no selection because its chip template lacks ${missing}.`
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
/** Expands the current template's localized remove-button name. */
|
|
909
|
+
#removeName(label, value) {
|
|
910
|
+
if (!this.hasTagTemplateTarget) return null;
|
|
911
|
+
const template = this.tagTemplateTarget.content.querySelector('button[data-stimeo--multi-select-target~="remove"]')?.getAttribute("aria-label")?.trim();
|
|
912
|
+
return template ? fillTemplate(template, { label, value }) : null;
|
|
588
913
|
}
|
|
589
914
|
/** Removes the chip mirroring `option`, if present. */
|
|
590
915
|
#removeTagFor(option) {
|
|
@@ -593,52 +918,30 @@ var MultiSelectController = class extends Controller {
|
|
|
593
918
|
tag?.remove();
|
|
594
919
|
}
|
|
595
920
|
/** Removes chip `index` and deselects its option, re-homing focus. */
|
|
596
|
-
#removeTagAt(index) {
|
|
921
|
+
#removeTagAt(index, focus = "neighbor") {
|
|
597
922
|
const tag = this.tagTargets[index];
|
|
598
923
|
if (!tag) return;
|
|
599
924
|
const value = tag.dataset.value ?? "";
|
|
600
925
|
const option = this.optionTargets.find((candidate) => this.#optionValue(candidate) === value);
|
|
601
926
|
if (option) option.setAttribute("aria-selected", "false");
|
|
602
927
|
tag.remove();
|
|
603
|
-
this.#announce(option ? this.#optionLabel(option) : value);
|
|
604
928
|
this.#refreshRoving();
|
|
605
929
|
this.#syncFields();
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
930
|
+
const values = this.#values;
|
|
931
|
+
this.#selectionValues = values;
|
|
932
|
+
this.#announceTransition(
|
|
933
|
+
false,
|
|
934
|
+
option ? this.#optionLabel(option) : value,
|
|
935
|
+
value,
|
|
936
|
+
values.length
|
|
937
|
+
);
|
|
938
|
+
this.dispatch("change", { detail: { values } });
|
|
939
|
+
if (focus === "input") {
|
|
940
|
+
this.#focusInput();
|
|
941
|
+
return;
|
|
612
942
|
}
|
|
943
|
+
if (!this.#chipRow.focusAfterRemoval(index)) this.#focusAfterLastTag();
|
|
613
944
|
}
|
|
614
|
-
/** Arrow navigation and deletion within the chip list (delegated). */
|
|
615
|
-
#onTagKeydown = (event) => {
|
|
616
|
-
if (event.defaultPrevented) return;
|
|
617
|
-
if (isReservedArrowChord(event)) return;
|
|
618
|
-
const button = event.target.closest("button");
|
|
619
|
-
if (!button) return;
|
|
620
|
-
const buttons = this.#removeButtons;
|
|
621
|
-
const index = buttons.indexOf(button);
|
|
622
|
-
if (index === -1) return;
|
|
623
|
-
switch (logicalArrowKey(event.key, this.element)) {
|
|
624
|
-
case "ArrowLeft":
|
|
625
|
-
if (index > 0) {
|
|
626
|
-
event.preventDefault();
|
|
627
|
-
this.#roving.setActive(index - 1, { focus: true });
|
|
628
|
-
}
|
|
629
|
-
break;
|
|
630
|
-
case "ArrowRight":
|
|
631
|
-
event.preventDefault();
|
|
632
|
-
if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
|
|
633
|
-
else this.#focusInput();
|
|
634
|
-
break;
|
|
635
|
-
case "Delete":
|
|
636
|
-
case "Backspace":
|
|
637
|
-
event.preventDefault();
|
|
638
|
-
this.#removeTagAt(index);
|
|
639
|
-
break;
|
|
640
|
-
}
|
|
641
|
-
};
|
|
642
945
|
/**
|
|
643
946
|
* Marks `option` active via `data-active` and the input's
|
|
644
947
|
* `aria-activedescendant` (the attribute is removed, not emptied, when null).
|
|
@@ -669,7 +972,7 @@ var MultiSelectController = class extends Controller {
|
|
|
669
972
|
const resolved = this.#activeOption;
|
|
670
973
|
const active = resolved && !resolved.hidden ? resolved : null;
|
|
671
974
|
const marked = this.optionTargets.filter((candidate) => candidate.hasAttribute("data-active"));
|
|
672
|
-
const idref = this.
|
|
975
|
+
const idref = this.inputTarget.getAttribute("aria-activedescendant");
|
|
673
976
|
if (!active || marked.length !== 1 || marked[0] !== active || idref !== activeId) {
|
|
674
977
|
this.#setActive(active);
|
|
675
978
|
}
|
|
@@ -703,12 +1006,19 @@ var MultiSelectController = class extends Controller {
|
|
|
703
1006
|
}
|
|
704
1007
|
/** Keeps exactly one chip remove button tabbable after the set changes. */
|
|
705
1008
|
#refreshRoving() {
|
|
706
|
-
|
|
707
|
-
this.#roving.setActive(0);
|
|
1009
|
+
this.#chipRow.ensureTabStop();
|
|
708
1010
|
}
|
|
709
|
-
/**
|
|
710
|
-
#
|
|
711
|
-
|
|
1011
|
+
/** Sends one localized selection transition through the page's shared announcer. */
|
|
1012
|
+
#announceTransition(selected, label, value, count) {
|
|
1013
|
+
const template = selected ? this.announceTextValue : this.announceRemovedTextValue;
|
|
1014
|
+
announce(fillTemplate(template, { label, value, count }));
|
|
1015
|
+
}
|
|
1016
|
+
/** Whether two arrays represent the same selection set, independent of DOM order. */
|
|
1017
|
+
#sameSelection(left, right) {
|
|
1018
|
+
if (left.length !== right.length) return false;
|
|
1019
|
+
const a = [...left].sort();
|
|
1020
|
+
const b = [...right].sort();
|
|
1021
|
+
return a.every((value, index) => value === b[index]);
|
|
712
1022
|
}
|
|
713
1023
|
/** Closes the list on a click outside the controller element. */
|
|
714
1024
|
#onOutsideClick = (event) => {
|
|
@@ -728,9 +1038,7 @@ var MultiSelectController = class extends Controller {
|
|
|
728
1038
|
}
|
|
729
1039
|
/** Current active target resolved by stable id, never a detached node reference. */
|
|
730
1040
|
get #activeOption() {
|
|
731
|
-
|
|
732
|
-
if (activeId === null) return null;
|
|
733
|
-
return this.optionTargets.find((option) => option.id === activeId) ?? null;
|
|
1041
|
+
return this.optionTargets.find((option) => option.id === this.#activeOptionId) ?? null;
|
|
734
1042
|
}
|
|
735
1043
|
/** Options currently selected. */
|
|
736
1044
|
get #selectedOptions() {
|
|
@@ -740,9 +1048,10 @@ var MultiSelectController = class extends Controller {
|
|
|
740
1048
|
get #values() {
|
|
741
1049
|
return this.#selectedOptions.map((option) => this.#optionValue(option));
|
|
742
1050
|
}
|
|
743
|
-
/**
|
|
744
|
-
get #
|
|
745
|
-
|
|
1051
|
+
/** Normalized cardinality cap: zero is unlimited and positive fractions round down. */
|
|
1052
|
+
get #selectionLimit() {
|
|
1053
|
+
if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
|
|
1054
|
+
return Math.max(1, Math.floor(this.maxValue));
|
|
746
1055
|
}
|
|
747
1056
|
/** Whether the list is currently hidden. */
|
|
748
1057
|
get #isClosed() {
|