stimeo-ui 0.2.1 → 0.3.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 +104 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +46 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +27 -3
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +32 -6
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stick_to_bottom_controller.js +2 -1
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +3514 -689
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -19,6 +19,22 @@ function ensureId(element, prefix = "stimeo") {
|
|
|
19
19
|
return id;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// src/utils/logical_scroll.ts
|
|
23
|
+
function isRtl(element) {
|
|
24
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/utils/arrow_step.ts
|
|
28
|
+
function logicalArrowKey(key, element) {
|
|
29
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
30
|
+
if (!isRtl(element)) return key;
|
|
31
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
32
|
+
}
|
|
33
|
+
function isReservedArrowChord(event, allow = []) {
|
|
34
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
35
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
// src/utils/composition_tracker.ts
|
|
23
39
|
var CompositionTracker = class {
|
|
24
40
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -66,6 +82,39 @@ var CompositionTracker = class {
|
|
|
66
82
|
};
|
|
67
83
|
};
|
|
68
84
|
|
|
85
|
+
// src/utils/microtask_coalescer.ts
|
|
86
|
+
var MicrotaskCoalescer = class {
|
|
87
|
+
#run;
|
|
88
|
+
#queued = false;
|
|
89
|
+
#active = false;
|
|
90
|
+
#generation = 0;
|
|
91
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
92
|
+
constructor(run) {
|
|
93
|
+
this.#run = run;
|
|
94
|
+
}
|
|
95
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
96
|
+
activate() {
|
|
97
|
+
this.#active = true;
|
|
98
|
+
}
|
|
99
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
100
|
+
cancel() {
|
|
101
|
+
this.#active = false;
|
|
102
|
+
this.#queued = false;
|
|
103
|
+
this.#generation += 1;
|
|
104
|
+
}
|
|
105
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
106
|
+
schedule() {
|
|
107
|
+
if (!this.#active || this.#queued) return;
|
|
108
|
+
this.#queued = true;
|
|
109
|
+
const generation = this.#generation;
|
|
110
|
+
queueMicrotask(() => {
|
|
111
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
112
|
+
this.#queued = false;
|
|
113
|
+
this.#run();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
69
118
|
// src/utils/option_scroll.ts
|
|
70
119
|
function scrollOptionIntoView(list, option) {
|
|
71
120
|
if (list.scrollHeight <= list.clientHeight) return;
|
|
@@ -111,6 +160,33 @@ var RovingTabindex = class {
|
|
|
111
160
|
}
|
|
112
161
|
};
|
|
113
162
|
|
|
163
|
+
// src/utils/tabindex_loan.ts
|
|
164
|
+
var TabindexLoan = class {
|
|
165
|
+
#value;
|
|
166
|
+
#lent = /* @__PURE__ */ new Set();
|
|
167
|
+
/**
|
|
168
|
+
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
169
|
+
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
170
|
+
* which a scroll region with no focusable content of its own needs.
|
|
171
|
+
*/
|
|
172
|
+
constructor(value = "-1") {
|
|
173
|
+
this.#value = value;
|
|
174
|
+
}
|
|
175
|
+
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
176
|
+
lend(element) {
|
|
177
|
+
if (element.hasAttribute("tabindex")) return;
|
|
178
|
+
element.setAttribute("tabindex", this.#value);
|
|
179
|
+
this.#lent.add(element);
|
|
180
|
+
}
|
|
181
|
+
/** Takes back every loan whose value is still the one that was lent. */
|
|
182
|
+
returnAll() {
|
|
183
|
+
for (const element of this.#lent) {
|
|
184
|
+
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
185
|
+
}
|
|
186
|
+
this.#lent.clear();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
114
190
|
// src/controllers/multi_select_controller.ts
|
|
115
191
|
var MultiSelectController = class extends Controller {
|
|
116
192
|
static targets = [
|
|
@@ -130,8 +206,14 @@ var MultiSelectController = class extends Controller {
|
|
|
130
206
|
};
|
|
131
207
|
static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
|
|
132
208
|
static events = ["change", "filter"];
|
|
133
|
-
/**
|
|
134
|
-
#
|
|
209
|
+
/** Stable id of the active option; the current target is resolved from the DOM. */
|
|
210
|
+
#activeOptionId = null;
|
|
211
|
+
/** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
|
|
212
|
+
#tabindex = new TabindexLoan();
|
|
213
|
+
/** Prevents initial/teardown target callbacks from mutating authored DOM. */
|
|
214
|
+
#connected = false;
|
|
215
|
+
/** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
|
|
216
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
|
|
135
217
|
/** Absorbs the browser's redundant final input after compositionend. */
|
|
136
218
|
#ignorePostCompositionInput = false;
|
|
137
219
|
/** Owns IME lifecycle state; confirmed text emits one filter result. */
|
|
@@ -151,30 +233,109 @@ var MultiSelectController = class extends Controller {
|
|
|
151
233
|
/** Starts closed, syncs chips for any pre-selected options, and listens out. */
|
|
152
234
|
connect() {
|
|
153
235
|
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
236
|
+
this.#normalizeSelection();
|
|
154
237
|
this.close();
|
|
155
238
|
if (this.hasTagsTarget) {
|
|
156
239
|
this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
|
|
157
240
|
this.tagsTarget.addEventListener("click", this.#onTagClick);
|
|
158
|
-
|
|
159
|
-
for (const option of this.#selectedOptions) this.#appendTag(option);
|
|
160
|
-
if (this.#removeButtons.length > 0) this.#roving.setActive(0);
|
|
241
|
+
this.#rebuildTags();
|
|
161
242
|
}
|
|
162
243
|
this.#syncFields();
|
|
163
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
244
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
245
|
+
this.#connected = true;
|
|
246
|
+
this.#reconcile.activate();
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Derives the chips from the selected options, idempotently: a Turbo Drive cache
|
|
250
|
+
* restore or morph can re-connect with chips already in the DOM, so they are
|
|
251
|
+
* cleared before deriving afresh to avoid duplicates.
|
|
252
|
+
*/
|
|
253
|
+
#rebuildTags() {
|
|
254
|
+
if (!this.hasTagsTarget) return;
|
|
255
|
+
for (const tag of this.tagTargets) tag.remove();
|
|
256
|
+
for (const option of this.#selectedOptions) this.#appendTag(option);
|
|
257
|
+
if (this.#removeButtons.length > 0) this.#roving.setActive(0);
|
|
164
258
|
}
|
|
165
259
|
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
166
260
|
disconnect() {
|
|
261
|
+
this.#connected = false;
|
|
262
|
+
this.#reconcile.cancel();
|
|
167
263
|
this.#composition.disconnect();
|
|
168
264
|
this.#ignorePostCompositionInput = false;
|
|
169
265
|
if (this.hasTagsTarget) {
|
|
170
266
|
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
171
267
|
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
172
268
|
}
|
|
173
|
-
document.removeEventListener("click", this.#onOutsideClick);
|
|
269
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
270
|
+
this.#releaseTabindex();
|
|
271
|
+
}
|
|
272
|
+
/** Reconciles active state after an option target is added at runtime. */
|
|
273
|
+
optionTargetConnected() {
|
|
274
|
+
this.#scheduleOptionReconcile();
|
|
174
275
|
}
|
|
175
|
-
/**
|
|
276
|
+
/** Cleans a removed target and reconciles active state against the surviving DOM. */
|
|
277
|
+
optionTargetDisconnected(option) {
|
|
278
|
+
if (!this.#connected) return;
|
|
279
|
+
option.removeAttribute("data-active");
|
|
280
|
+
this.#scheduleOptionReconcile();
|
|
281
|
+
}
|
|
282
|
+
/** Schedules one reconciliation after all callbacks in the mutation batch. */
|
|
283
|
+
#scheduleOptionReconcile() {
|
|
284
|
+
this.#reconcile.schedule();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Keeps a surviving/same-id active target, otherwise falls back to the first
|
|
288
|
+
* visible one — and brings the derived state back in line with the new option set.
|
|
289
|
+
*
|
|
290
|
+
* The baseline pass fills in any missing `aria-selected`, and the chips and
|
|
291
|
+
* hidden fields are re-derived from it, because the options are the truth source
|
|
292
|
+
* for the selection. The chips are rebuilt **only when the selected value set
|
|
293
|
+
* actually moved**: the rebuild removes and recreates every chip, so running it
|
|
294
|
+
* for an unrelated option would drop focus from a chip's remove button to
|
|
295
|
+
* `<body>`, losing the keyboard user's place for something that did not concern
|
|
296
|
+
* them.
|
|
297
|
+
*/
|
|
298
|
+
#reconcileOptions() {
|
|
299
|
+
const visible = this.#visibleOptions;
|
|
300
|
+
const active = this.#activeOption;
|
|
301
|
+
const next = this.#isClosed ? null : active && !active.hidden ? active : visible[0] ?? null;
|
|
302
|
+
this.#setActive(next);
|
|
303
|
+
this.#reflectEmpty();
|
|
304
|
+
this.#normalizeSelection();
|
|
305
|
+
const selected = this.#selectedOptions;
|
|
306
|
+
const nextValues = selected.map((option) => this.#optionValue(option)).sort();
|
|
307
|
+
const tagValues = this.tagTargets.map((tag) => tag.dataset.value ?? "").sort();
|
|
308
|
+
const unchanged = nextValues.length === tagValues.length && nextValues.every((value, index) => value === tagValues[index]);
|
|
309
|
+
if (unchanged) this.#refreshTagLabels(selected);
|
|
310
|
+
else this.#rebuildTags();
|
|
311
|
+
this.#syncFields();
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Gives every option an explicit `aria-selected`, without changing which ones
|
|
315
|
+
* the author chose. An absent value means "not selectable" in ARIA, so a
|
|
316
|
+
* forgotten attribute hides a selectable option. Several `true` is the normal
|
|
317
|
+
* case here — the list is `aria-multiselectable` — so nothing is dropped.
|
|
318
|
+
*/
|
|
319
|
+
#normalizeSelection() {
|
|
320
|
+
for (const option of this.optionTargets) {
|
|
321
|
+
if (option.getAttribute("aria-selected") !== "true") {
|
|
322
|
+
option.setAttribute("aria-selected", "false");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Tracks an input added initially or after connect, and makes it describe the
|
|
328
|
+
* widget that is actually on screen: a swapped-in input arrives with the
|
|
329
|
+
* authored ARIA of a fresh node while this controller still holds the popup
|
|
330
|
+
* state, and the open path cannot repair that (it seeds an active option only
|
|
331
|
+
* when there is none), so a live list would go unannounced.
|
|
332
|
+
*/
|
|
176
333
|
inputTargetConnected(input) {
|
|
177
334
|
this.#composition.observe(input);
|
|
335
|
+
input.setAttribute("aria-expanded", String(!this.#isClosed));
|
|
336
|
+
const active = this.#activeOption;
|
|
337
|
+
if (active) input.setAttribute("aria-activedescendant", ensureId(active, "stimeo-ms-opt"));
|
|
338
|
+
else input.removeAttribute("aria-activedescendant");
|
|
178
339
|
}
|
|
179
340
|
/** Removes composition listeners when the active input is replaced or removed. */
|
|
180
341
|
inputTargetDisconnected(input) {
|
|
@@ -195,28 +356,48 @@ var MultiSelectController = class extends Controller {
|
|
|
195
356
|
}
|
|
196
357
|
this.open();
|
|
197
358
|
const visible = this.#visibleOptions;
|
|
198
|
-
this
|
|
359
|
+
this.#reflectEmpty();
|
|
199
360
|
this.#setActive(visible[0] ?? null);
|
|
200
361
|
this.dispatch("filter", { detail: { query } });
|
|
201
362
|
}
|
|
202
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* Opens the list and activates the first visible option when none is active.
|
|
365
|
+
*
|
|
366
|
+
* Needs the input, which owns `aria-expanded` and `aria-activedescendant`: a
|
|
367
|
+
* list shown without one is a popup no assistive technology is told about. So
|
|
368
|
+
* opening is skipped entirely, where {@link close} still closes.
|
|
369
|
+
*/
|
|
203
370
|
open() {
|
|
204
|
-
if (!this.hasListTarget) return;
|
|
371
|
+
if (!this.hasListTarget || !this.hasInputTarget) return;
|
|
205
372
|
this.listTarget.hidden = false;
|
|
206
373
|
this.inputTarget.setAttribute("aria-expanded", "true");
|
|
207
374
|
if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
|
|
208
375
|
}
|
|
209
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* Closes the list and clears the active option.
|
|
378
|
+
*
|
|
379
|
+
* Survives a missing input in both directions. `connect()` calls this second,
|
|
380
|
+
* so dereferencing the input here would throw before the chips, the roving
|
|
381
|
+
* seed, the chip listeners, the hidden fields and the outside-click listener —
|
|
382
|
+
* and Stimulus keeps the controller alive after that throw, so none of them
|
|
383
|
+
* would ever run and the selection would silently stop submitting. An input
|
|
384
|
+
* removed while the list is open must still let it come down *and* forget its
|
|
385
|
+
* active option, so only the `aria-expanded` write is guarded.
|
|
386
|
+
*/
|
|
210
387
|
close() {
|
|
211
388
|
if (!this.hasListTarget) return;
|
|
212
389
|
this.listTarget.hidden = true;
|
|
213
|
-
this.inputTarget.setAttribute("aria-expanded", "false");
|
|
214
390
|
this.#setActive(null);
|
|
391
|
+
if (!this.hasInputTarget) return;
|
|
392
|
+
this.inputTarget.setAttribute("aria-expanded", "false");
|
|
215
393
|
}
|
|
216
394
|
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
217
395
|
onKeydown(event) {
|
|
396
|
+
if (event.defaultPrevented) return;
|
|
397
|
+
if (isReservedArrowChord(event)) return;
|
|
218
398
|
if (this.#composition.isComposing(event)) return;
|
|
219
|
-
|
|
399
|
+
this.#reconcileActiveForInteraction();
|
|
400
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
220
401
|
case "ArrowDown":
|
|
221
402
|
event.preventDefault();
|
|
222
403
|
if (this.#isClosed) this.open();
|
|
@@ -241,14 +422,16 @@ var MultiSelectController = class extends Controller {
|
|
|
241
422
|
}
|
|
242
423
|
break;
|
|
243
424
|
}
|
|
244
|
-
case "Enter":
|
|
245
|
-
|
|
425
|
+
case "Enter": {
|
|
426
|
+
const active = this.#activeOption;
|
|
427
|
+
if (!this.#isClosed && active) {
|
|
246
428
|
event.preventDefault();
|
|
247
|
-
this.#toggleSelection(
|
|
429
|
+
this.#toggleSelection(active);
|
|
248
430
|
}
|
|
249
431
|
break;
|
|
432
|
+
}
|
|
250
433
|
case "Escape":
|
|
251
|
-
if (
|
|
434
|
+
if (this.#isClosed) break;
|
|
252
435
|
event.preventDefault();
|
|
253
436
|
this.close();
|
|
254
437
|
break;
|
|
@@ -281,9 +464,50 @@ var MultiSelectController = class extends Controller {
|
|
|
281
464
|
*/
|
|
282
465
|
toggleOption(event) {
|
|
283
466
|
const option = event.currentTarget.closest('[role="option"]');
|
|
284
|
-
if (!option) return;
|
|
467
|
+
if (!option || !this.optionTargets.includes(option)) return;
|
|
285
468
|
this.#toggleSelection(option);
|
|
286
|
-
this
|
|
469
|
+
this.#focusInput();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Re-homes focus to the input, or leaves it where it is when there is none.
|
|
473
|
+
*
|
|
474
|
+
* All three callers run *after* an option or a chip already took focus, and all
|
|
475
|
+
* three are reachable without an input — options and chips carry their own
|
|
476
|
+
* `data-action`. Throwing here would leave the chip removed but focus stranded
|
|
477
|
+
* on a detached button.
|
|
478
|
+
*/
|
|
479
|
+
#focusInput() {
|
|
480
|
+
if (this.hasInputTarget) this.inputTarget.focus();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Re-homes focus after the last chip was removed: to the input, else the root.
|
|
484
|
+
*
|
|
485
|
+
* Unlike the other {@link #focusInput} callers, the element that held focus has
|
|
486
|
+
* just left the DOM, so "leave it alone" is not an option — the browser already
|
|
487
|
+
* 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 on a real element is
|
|
489
|
+
* left alone, so a chip removed out of band never steals it.
|
|
490
|
+
*/
|
|
491
|
+
#focusAfterLastTag() {
|
|
492
|
+
if (this.hasInputTarget) {
|
|
493
|
+
this.inputTarget.focus();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const doc = this.element.ownerDocument;
|
|
497
|
+
const active = doc.activeElement;
|
|
498
|
+
if (active && active !== doc.body && active !== doc.documentElement && active.isConnected) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
this.#tabindex.lend(this.element);
|
|
502
|
+
this.element.focus();
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Returns the borrowed tab stop. Owning the borrow is not enough — the value
|
|
506
|
+
* has to still be the one this instance wrote, since a consumer that changed it
|
|
507
|
+
* afterwards owns it now.
|
|
508
|
+
*/
|
|
509
|
+
#releaseTabindex() {
|
|
510
|
+
this.#tabindex.returnAll();
|
|
287
511
|
}
|
|
288
512
|
/**
|
|
289
513
|
* Removes the chip whose remove button was clicked, deselecting its option.
|
|
@@ -302,7 +526,8 @@ var MultiSelectController = class extends Controller {
|
|
|
302
526
|
const visible = this.#visibleOptions;
|
|
303
527
|
if (visible.length === 0) return;
|
|
304
528
|
const current = this.#activeOption ? visible.indexOf(this.#activeOption) : -1;
|
|
305
|
-
const
|
|
529
|
+
const candidate = current === -1 ? delta > 0 ? 0 : visible.length - 1 : current + delta;
|
|
530
|
+
const next = (candidate + visible.length) % visible.length;
|
|
306
531
|
this.#setActive(visible[next] ?? null);
|
|
307
532
|
}
|
|
308
533
|
/** Selects/deselects `option`, honoring `max`, and syncs chip + live region. */
|
|
@@ -322,6 +547,30 @@ var MultiSelectController = class extends Controller {
|
|
|
322
547
|
this.#syncFields();
|
|
323
548
|
this.dispatch("change", { detail: { values: this.#values } });
|
|
324
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* Re-reads each chip's label from its option, in place.
|
|
552
|
+
*
|
|
553
|
+
* The value order alone does not say the chips are still correct: a server can
|
|
554
|
+
* re-render the same candidate with a new label ("Apple" → "Green Apple"), and
|
|
555
|
+
* the chip text and its `Remove {label}` name are both derived from the option.
|
|
556
|
+
* Updating them here keeps the rebuild — which would drop focus — for the case
|
|
557
|
+
* that actually needs it, a changed selection.
|
|
558
|
+
*/
|
|
559
|
+
#refreshTagLabels(selected) {
|
|
560
|
+
const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
|
|
561
|
+
for (const tag of this.tagTargets) {
|
|
562
|
+
const option = options.get(tag.dataset.value ?? "");
|
|
563
|
+
if (!option) continue;
|
|
564
|
+
const text = this.#optionLabel(option);
|
|
565
|
+
const label = tag.querySelector('[data-multi-select-slot="label"]');
|
|
566
|
+
if (label && label.textContent !== text) label.textContent = text;
|
|
567
|
+
const button = tag.querySelector("button");
|
|
568
|
+
const name = `Remove ${text}`;
|
|
569
|
+
if (button && button.getAttribute("aria-label") !== name) {
|
|
570
|
+
button.setAttribute("aria-label", name);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
325
574
|
/** Builds one chip from the template for `option`. */
|
|
326
575
|
#appendTag(option) {
|
|
327
576
|
if (!this.hasTagTemplateTarget || !this.hasTagsTarget) return;
|
|
@@ -357,19 +606,21 @@ var MultiSelectController = class extends Controller {
|
|
|
357
606
|
this.dispatch("change", { detail: { values: this.#values } });
|
|
358
607
|
const remaining = this.#removeButtons;
|
|
359
608
|
if (remaining.length === 0) {
|
|
360
|
-
this
|
|
609
|
+
this.#focusAfterLastTag();
|
|
361
610
|
} else {
|
|
362
611
|
this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
|
|
363
612
|
}
|
|
364
613
|
}
|
|
365
614
|
/** Arrow navigation and deletion within the chip list (delegated). */
|
|
366
615
|
#onTagKeydown = (event) => {
|
|
616
|
+
if (event.defaultPrevented) return;
|
|
617
|
+
if (isReservedArrowChord(event)) return;
|
|
367
618
|
const button = event.target.closest("button");
|
|
368
619
|
if (!button) return;
|
|
369
620
|
const buttons = this.#removeButtons;
|
|
370
621
|
const index = buttons.indexOf(button);
|
|
371
622
|
if (index === -1) return;
|
|
372
|
-
switch (event.key) {
|
|
623
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
373
624
|
case "ArrowLeft":
|
|
374
625
|
if (index > 0) {
|
|
375
626
|
event.preventDefault();
|
|
@@ -379,7 +630,7 @@ var MultiSelectController = class extends Controller {
|
|
|
379
630
|
case "ArrowRight":
|
|
380
631
|
event.preventDefault();
|
|
381
632
|
if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
|
|
382
|
-
else this
|
|
633
|
+
else this.#focusInput();
|
|
383
634
|
break;
|
|
384
635
|
case "Delete":
|
|
385
636
|
case "Backspace":
|
|
@@ -391,23 +642,49 @@ var MultiSelectController = class extends Controller {
|
|
|
391
642
|
/**
|
|
392
643
|
* Marks `option` active via `data-active` and the input's
|
|
393
644
|
* `aria-activedescendant` (the attribute is removed, not emptied, when null).
|
|
645
|
+
*
|
|
646
|
+
* The state half runs even with no input, so a `close()` that cannot touch ARIA
|
|
647
|
+
* still clears it: {@link open} seeds an active option only when there is none,
|
|
648
|
+
* so a stale one makes the next open skip the seeding and a replacement input
|
|
649
|
+
* gets no `aria-activedescendant` at all.
|
|
394
650
|
*/
|
|
395
651
|
#setActive(option) {
|
|
396
|
-
|
|
652
|
+
const activeId = option ? ensureId(option, "stimeo-ms-opt") : null;
|
|
653
|
+
this.#activeOptionId = activeId;
|
|
397
654
|
for (const candidate of this.optionTargets) {
|
|
398
655
|
candidate.toggleAttribute("data-active", candidate === option);
|
|
399
656
|
}
|
|
400
|
-
if (option)
|
|
401
|
-
|
|
402
|
-
|
|
657
|
+
if (option && this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
|
|
658
|
+
if (!this.hasInputTarget) return;
|
|
659
|
+
if (activeId !== null) {
|
|
660
|
+
this.inputTarget.setAttribute("aria-activedescendant", activeId);
|
|
403
661
|
} else {
|
|
404
662
|
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
405
663
|
}
|
|
406
664
|
}
|
|
665
|
+
/** Repairs only active identity before a key; the fallback waits for the target callback. */
|
|
666
|
+
#reconcileActiveForInteraction() {
|
|
667
|
+
const activeId = this.#activeOptionId;
|
|
668
|
+
if (activeId === null) return;
|
|
669
|
+
const resolved = this.#activeOption;
|
|
670
|
+
const active = resolved && !resolved.hidden ? resolved : null;
|
|
671
|
+
const marked = this.optionTargets.filter((candidate) => candidate.hasAttribute("data-active"));
|
|
672
|
+
const idref = this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null;
|
|
673
|
+
if (!active || marked.length !== 1 || marked[0] !== active || idref !== activeId) {
|
|
674
|
+
this.#setActive(active);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/** Reflects whether the open list currently has no visible option targets. */
|
|
678
|
+
#reflectEmpty() {
|
|
679
|
+
this.element.toggleAttribute(
|
|
680
|
+
"data-stimeo--multi-select-empty",
|
|
681
|
+
!this.#isClosed && this.#visibleOptions.length === 0
|
|
682
|
+
);
|
|
683
|
+
}
|
|
407
684
|
/**
|
|
408
685
|
* Mirrors the selected values into named hidden inputs under the `fields`
|
|
409
686
|
* target so the selection submits with a normal form (parity with tags-input).
|
|
410
|
-
* No-ops without a `fields` target
|
|
687
|
+
* No-ops without a `fields` target. When the
|
|
411
688
|
* `form` value is set, each input gets a matching `form` attribute so the picker
|
|
412
689
|
* can submit with a `<form>` it lives outside of.
|
|
413
690
|
*/
|
|
@@ -449,6 +726,12 @@ var MultiSelectController = class extends Controller {
|
|
|
449
726
|
get #visibleOptions() {
|
|
450
727
|
return this.optionTargets.filter((option) => !option.hidden);
|
|
451
728
|
}
|
|
729
|
+
/** Current active target resolved by stable id, never a detached node reference. */
|
|
730
|
+
get #activeOption() {
|
|
731
|
+
const activeId = this.#activeOptionId;
|
|
732
|
+
if (activeId === null) return null;
|
|
733
|
+
return this.optionTargets.find((option) => option.id === activeId) ?? null;
|
|
734
|
+
}
|
|
452
735
|
/** Options currently selected. */
|
|
453
736
|
get #selectedOptions() {
|
|
454
737
|
return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");
|