stimeo-ui 0.14.0 → 0.16.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 +212 -0
- data/README.md +120 -0
- data/dist/cable/index.js +123 -29
- data/dist/controllers/accordion_controller.js +98 -9
- data/dist/controllers/announcer_controller.js +96 -62
- data/dist/controllers/auto_submit_controller.js +83 -7
- data/dist/controllers/avatar_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +38 -11
- data/dist/controllers/bulk_select_controller.js +7 -6
- data/dist/controllers/calendar_controller.js +340 -123
- data/dist/controllers/carousel_controller.js +263 -38
- data/dist/controllers/character_counter_controller.js +40 -2
- data/dist/controllers/checkbox_controller.js +81 -12
- data/dist/controllers/clipboard_controller.js +63 -5
- data/dist/controllers/collapsible_controller.js +99 -14
- data/dist/controllers/color_picker_controller.js +80 -34
- data/dist/controllers/combobox_controller.js +106 -16
- data/dist/controllers/command_palette_controller.js +35 -3
- data/dist/controllers/conditional_fields_controller.js +85 -17
- data/dist/controllers/confirm_controller.js +3 -0
- data/dist/controllers/context_menu_controller.js +32 -12
- data/dist/controllers/countdown_controller.js +129 -26
- data/dist/controllers/currency_input_controller.js +221 -67
- data/dist/controllers/data_grid_controller.js +195 -29
- data/dist/controllers/date_range_picker_controller.js +151 -30
- data/dist/controllers/dialog_controller.js +35 -8
- data/dist/controllers/direct_upload_controller.js +22 -4
- data/dist/controllers/dirty_form_controller.js +46 -13
- data/dist/controllers/dismissible_controller.js +1 -0
- data/dist/controllers/drawer_controller.js +54 -19
- data/dist/controllers/dropdown_controller.js +36 -9
- data/dist/controllers/editable_controller.js +34 -0
- data/dist/controllers/file_dropzone_controller.js +144 -51
- data/dist/controllers/filter_controller.js +20 -6
- data/dist/controllers/flash_controller.js +432 -71
- data/dist/controllers/focus_controller.js +1 -0
- data/dist/controllers/form_field_controller.js +7 -5
- data/dist/controllers/form_validation_controller.js +19 -13
- data/dist/controllers/frame_loading_controller.js +45 -8
- data/dist/controllers/highlight_controller.js +82 -25
- data/dist/controllers/hover_card_controller.js +40 -14
- data/dist/controllers/idle_controller.js +90 -5
- data/dist/controllers/input_mask_controller.js +65 -9
- data/dist/controllers/intersection_controller.js +3 -0
- data/dist/controllers/lazy_frame_controller.js +11 -2
- data/dist/controllers/listbox_controller.js +203 -45
- data/dist/controllers/local_time_controller.js +10 -5
- data/dist/controllers/masonry_controller.js +31 -15
- data/dist/controllers/menu_controller.js +45 -16
- data/dist/controllers/menubar_controller.js +58 -24
- data/dist/controllers/meter_controller.js +9 -5
- data/dist/controllers/multi_select_controller.js +278 -104
- data/dist/controllers/navigation_menu_controller.js +48 -15
- data/dist/controllers/nested_form_controller.js +37 -8
- data/dist/controllers/network_status_controller.js +9 -1
- data/dist/controllers/number_input_controller.js +124 -21
- data/dist/controllers/optimistic_controller.js +42 -5
- data/dist/controllers/otp_controller.js +198 -55
- data/dist/controllers/overflow_indicator_controller.js +115 -21
- data/dist/controllers/overflow_menu_controller.js +141 -44
- data/dist/controllers/pagination_controller.js +74 -28
- data/dist/controllers/password_reveal_controller.js +59 -2
- data/dist/controllers/persist_controller.js +30 -8
- data/dist/controllers/pointer_drag_controller.js +131 -52
- data/dist/controllers/popover_controller.js +45 -11
- data/dist/controllers/portal_controller.js +6 -2
- data/dist/controllers/preview_guard_controller.js +16 -1
- data/dist/controllers/progress_controller.js +8 -4
- data/dist/controllers/radio_group_controller.js +42 -17
- data/dist/controllers/range_slider_controller.js +88 -42
- data/dist/controllers/rating_controller.js +39 -15
- data/dist/controllers/read_more_controller.js +100 -7
- data/dist/controllers/reading_progress_controller.js +65 -19
- data/dist/controllers/relative_time_controller.js +10 -5
- data/dist/controllers/resizable_controller.js +82 -22
- data/dist/controllers/scroll_area_controller.js +75 -27
- data/dist/controllers/scroll_restore_controller.js +37 -16
- data/dist/controllers/scroll_visibility_controller.js +49 -30
- data/dist/controllers/scrollspy_controller.js +71 -26
- data/dist/controllers/separator_controller.js +66 -37
- data/dist/controllers/sidebar_controller.js +77 -18
- data/dist/controllers/skeleton_controller.js +6 -1
- data/dist/controllers/slider_controller.js +82 -47
- data/dist/controllers/smart_sticky_header_controller.js +60 -26
- data/dist/controllers/sortable_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +10 -2
- data/dist/controllers/step_indicator_controller.js +18 -17
- data/dist/controllers/stepper_controller.js +101 -19
- data/dist/controllers/stick_to_bottom_controller.js +104 -8
- data/dist/controllers/submit_once_controller.js +45 -9
- data/dist/controllers/switch_controller.js +101 -10
- data/dist/controllers/tabs_controller.js +21 -2
- data/dist/controllers/tags_input_controller.js +209 -59
- data/dist/controllers/textarea_autosize_controller.js +29 -3
- data/dist/controllers/theme_controller.js +64 -14
- data/dist/controllers/time_picker_controller.js +23 -8
- data/dist/controllers/toast_controller.js +451 -105
- data/dist/controllers/toggle_group_controller.js +159 -23
- data/dist/controllers/toolbar_controller.js +32 -0
- data/dist/controllers/tooltip_controller.js +39 -13
- data/dist/controllers/transition_controller.js +4 -0
- data/dist/controllers/tree_view_controller.js +169 -16
- data/dist/index.js +5002 -1911
- data/dist/positioning/index.js +2 -0
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -35,6 +35,25 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
35
35
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// src/utils/element_part.ts
|
|
39
|
+
function matchingPart(root, selector) {
|
|
40
|
+
return root.matches(selector) ? root : root.querySelector(selector);
|
|
41
|
+
}
|
|
42
|
+
function writeLabel(slot, text) {
|
|
43
|
+
if (slot.childElementCount === 0) {
|
|
44
|
+
slot.textContent = text;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let first = null;
|
|
48
|
+
for (const node of Array.from(slot.childNodes)) {
|
|
49
|
+
if (node.nodeType !== Node.TEXT_NODE) continue;
|
|
50
|
+
if (first) node.remove();
|
|
51
|
+
else first = node;
|
|
52
|
+
}
|
|
53
|
+
if (first) first.nodeValue = text;
|
|
54
|
+
else slot.prepend(text);
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
// src/utils/roving_tabindex.ts
|
|
39
58
|
var RovingTabindex = class {
|
|
40
59
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -82,7 +101,7 @@ var ChipRow = class {
|
|
|
82
101
|
constructor(options) {
|
|
83
102
|
this.#directionElement = options.directionElement;
|
|
84
103
|
this.#getItems = options.getItems;
|
|
85
|
-
this.#getButton = options.getButton ?? ((item) => item
|
|
104
|
+
this.#getButton = options.getButton ?? ((item) => matchingPart(item, "button"));
|
|
86
105
|
this.#onRemove = options.onRemove;
|
|
87
106
|
this.#focusAfterEnd = options.focusAfterEnd;
|
|
88
107
|
}
|
|
@@ -207,11 +226,19 @@ var ChipRow = class {
|
|
|
207
226
|
};
|
|
208
227
|
|
|
209
228
|
// src/utils/composition_tracker.ts
|
|
229
|
+
var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
230
|
+
"insertCompositionText",
|
|
231
|
+
"insertFromComposition",
|
|
232
|
+
"deleteCompositionText",
|
|
233
|
+
"deleteByComposition"
|
|
234
|
+
]);
|
|
210
235
|
var CompositionTracker = class {
|
|
211
236
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
212
237
|
#activeTargets = /* @__PURE__ */ new Set();
|
|
213
238
|
#onStart;
|
|
214
239
|
#onEnd;
|
|
240
|
+
/** The field whose confirming `input` is still owed, while the window is open. */
|
|
241
|
+
#confirmedTarget = null;
|
|
215
242
|
constructor(options = {}) {
|
|
216
243
|
this.#onStart = options.onStart;
|
|
217
244
|
this.#onEnd = options.onEnd;
|
|
@@ -221,6 +248,7 @@ var CompositionTracker = class {
|
|
|
221
248
|
if (this.#observedTargets.has(target)) return;
|
|
222
249
|
target.addEventListener("compositionstart", this.#handleStart);
|
|
223
250
|
target.addEventListener("compositionend", this.#handleEnd);
|
|
251
|
+
target.addEventListener("keydown", this.#handleKeydown);
|
|
224
252
|
this.#observedTargets.add(target);
|
|
225
253
|
}
|
|
226
254
|
/** Stops tracking one target and clears any active composition it owned. */
|
|
@@ -228,31 +256,77 @@ var CompositionTracker = class {
|
|
|
228
256
|
if (!this.#observedTargets.delete(target)) return;
|
|
229
257
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
230
258
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
259
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
231
260
|
this.#activeTargets.delete(target);
|
|
261
|
+
if (this.#confirmedTarget === target) this.#confirmedTarget = null;
|
|
232
262
|
}
|
|
233
263
|
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
234
264
|
disconnect() {
|
|
235
265
|
for (const target of this.#observedTargets) {
|
|
236
266
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
237
267
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
268
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
238
269
|
}
|
|
239
270
|
this.#observedTargets.clear();
|
|
240
271
|
this.#activeTargets.clear();
|
|
272
|
+
this.#confirmedTarget = null;
|
|
241
273
|
}
|
|
242
274
|
/** True when lifecycle tracking or the current event reports composition. */
|
|
243
275
|
isComposing(event) {
|
|
244
276
|
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
245
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Whether `event` is the `input` echoing the composition just confirmed.
|
|
280
|
+
*
|
|
281
|
+
* Asking closes the window either way, so one confirmation is folded at most
|
|
282
|
+
* once and a consumer asks once per `input`.
|
|
283
|
+
*/
|
|
284
|
+
consumesConfirmedInput(event) {
|
|
285
|
+
const confirmed = this.#confirmedTarget;
|
|
286
|
+
this.#confirmedTarget = null;
|
|
287
|
+
if (confirmed === null || confirmed !== event.target) return false;
|
|
288
|
+
const inputType = event.inputType;
|
|
289
|
+
return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
|
|
290
|
+
}
|
|
246
291
|
#handleStart = (event) => {
|
|
292
|
+
this.#confirmedTarget = null;
|
|
247
293
|
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
248
294
|
this.#onStart?.(event);
|
|
249
295
|
};
|
|
250
296
|
#handleEnd = (event) => {
|
|
251
297
|
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
298
|
+
this.#confirmedTarget = event.target;
|
|
252
299
|
this.#onEnd?.(event);
|
|
253
300
|
};
|
|
301
|
+
/** A key on an observed field opens an edit of its own, so no echo is owed. */
|
|
302
|
+
#handleKeydown = () => {
|
|
303
|
+
this.#confirmedTarget = null;
|
|
304
|
+
};
|
|
254
305
|
};
|
|
255
306
|
|
|
307
|
+
// src/utils/field_mirror.ts
|
|
308
|
+
function writeFields(container, values, { name, form = "" }) {
|
|
309
|
+
const current = [...container.children];
|
|
310
|
+
const submits = current.length === values.length && current.every(
|
|
311
|
+
(child, index) => child instanceof HTMLInputElement && child.type === "hidden" && child.value === values[index] && child.name === name && (child.getAttribute("form") ?? "") === form
|
|
312
|
+
);
|
|
313
|
+
if (submits) return false;
|
|
314
|
+
container.replaceChildren(
|
|
315
|
+
...values.map((value) => {
|
|
316
|
+
const input = document.createElement("input");
|
|
317
|
+
input.type = "hidden";
|
|
318
|
+
input.name = name;
|
|
319
|
+
input.value = value;
|
|
320
|
+
if (form !== "") input.setAttribute("form", form);
|
|
321
|
+
return input;
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
function commitField(target) {
|
|
327
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
328
|
+
}
|
|
329
|
+
|
|
256
330
|
// src/utils/microtask_coalescer.ts
|
|
257
331
|
var MicrotaskCoalescer = class {
|
|
258
332
|
#run;
|
|
@@ -286,6 +360,85 @@ var MicrotaskCoalescer = class {
|
|
|
286
360
|
}
|
|
287
361
|
};
|
|
288
362
|
|
|
363
|
+
// src/utils/target_selector.ts
|
|
364
|
+
function targetSelector(identifier, name) {
|
|
365
|
+
return `[data-${identifier}-target~="${name}"]`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/utils/template_row.ts
|
|
369
|
+
function cloneTemplateRoot(template) {
|
|
370
|
+
const root = template.content.firstElementChild;
|
|
371
|
+
return root instanceof HTMLElement ? root.cloneNode(true) : null;
|
|
372
|
+
}
|
|
373
|
+
var TemplateRow = class {
|
|
374
|
+
#options;
|
|
375
|
+
#warned = false;
|
|
376
|
+
constructor(options) {
|
|
377
|
+
this.#options = options;
|
|
378
|
+
}
|
|
379
|
+
/** The attribute selector for one declared part, in this controller's namespace. */
|
|
380
|
+
selector(name) {
|
|
381
|
+
return targetSelector(this.#options.identifier, name);
|
|
382
|
+
}
|
|
383
|
+
/** Re-arms the once-per-connection diagnostic. */
|
|
384
|
+
connect() {
|
|
385
|
+
this.#warned = false;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Names one missing part on the console, at most once per connection, and
|
|
389
|
+
* returns `null` so a caller can hand it straight back.
|
|
390
|
+
*/
|
|
391
|
+
report(missing) {
|
|
392
|
+
return this.#say(`lacks ${missing}`);
|
|
393
|
+
}
|
|
394
|
+
/** Writes one diagnostic per connection, saying what the template got wrong. */
|
|
395
|
+
#say(problem) {
|
|
396
|
+
if (this.#warned) return null;
|
|
397
|
+
this.#warned = true;
|
|
398
|
+
const { identifier, outcome, noun } = this.#options;
|
|
399
|
+
console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Clones the row and resolves its parts, or names what the template got wrong
|
|
404
|
+
* and returns `null`. `values` fills the button's authored `aria-label`.
|
|
405
|
+
*/
|
|
406
|
+
instantiate(template, values) {
|
|
407
|
+
const { root: rootName, required, optional, button: buttonName } = this.#options;
|
|
408
|
+
const root = cloneTemplateRoot(template);
|
|
409
|
+
if (!root?.matches(this.selector(rootName))) {
|
|
410
|
+
return this.report(`${this.#article(rootName)} "${rootName}" root`);
|
|
411
|
+
}
|
|
412
|
+
const count = template.content.children.length;
|
|
413
|
+
if (count !== 1) {
|
|
414
|
+
return this.#say(`holds ${count} elements, and the row has to be its only one`);
|
|
415
|
+
}
|
|
416
|
+
const slots = {};
|
|
417
|
+
for (const name2 of required) {
|
|
418
|
+
const part = this.#resolve(root, name2);
|
|
419
|
+
if (!part) return this.report(`${this.#article(name2)} "${name2}" target`);
|
|
420
|
+
slots[name2] = part;
|
|
421
|
+
}
|
|
422
|
+
for (const name2 of optional ?? []) slots[name2] = this.#resolve(root, name2);
|
|
423
|
+
const named = this.#resolve(root, buttonName);
|
|
424
|
+
if (!(named instanceof HTMLButtonElement)) {
|
|
425
|
+
return this.report(`${this.#article(buttonName)} "${buttonName}" target <button>`);
|
|
426
|
+
}
|
|
427
|
+
const button = named;
|
|
428
|
+
const name = button.getAttribute("aria-label")?.trim() ?? "";
|
|
429
|
+
if (name === "") return this.report(`a non-empty aria-label on its "${buttonName}" target`);
|
|
430
|
+
button.setAttribute("aria-label", fillTemplate(name, values));
|
|
431
|
+
return { root, slots, button };
|
|
432
|
+
}
|
|
433
|
+
/** The row may be its own part; otherwise the first descendant carrying the target. */
|
|
434
|
+
#resolve(root, name) {
|
|
435
|
+
return matchingPart(root, this.selector(name));
|
|
436
|
+
}
|
|
437
|
+
#article(name) {
|
|
438
|
+
return /^[aeiou]/i.test(name) ? "an" : "a";
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
289
442
|
// src/controllers/tags_input_controller.ts
|
|
290
443
|
var TagsInputController = class extends Controller {
|
|
291
444
|
static targets = ["input", "tags", "tag", "tagTemplate", "label", "remove", "fields"];
|
|
@@ -294,6 +447,7 @@ var TagsInputController = class extends Controller {
|
|
|
294
447
|
max: { type: Number, default: 0 },
|
|
295
448
|
allowDuplicates: { type: Boolean, default: false },
|
|
296
449
|
name: { type: String, default: "tags[]" },
|
|
450
|
+
form: { type: String, default: "" },
|
|
297
451
|
announceText: { type: String, default: "" },
|
|
298
452
|
announceRemovedText: { type: String, default: "" }
|
|
299
453
|
};
|
|
@@ -301,14 +455,21 @@ var TagsInputController = class extends Controller {
|
|
|
301
455
|
static events = ["change", "reconcile", "reject"];
|
|
302
456
|
/** Last reconciled tag order, separating user edits from DOM/Turbo repair. */
|
|
303
457
|
#tagValues = [];
|
|
304
|
-
/**
|
|
305
|
-
#
|
|
458
|
+
/** Builds one chip from the authored template and owns its diagnostic. */
|
|
459
|
+
#rows = new TemplateRow({
|
|
460
|
+
identifier: this.identifier,
|
|
461
|
+
root: "tag",
|
|
462
|
+
required: ["label"],
|
|
463
|
+
button: "remove",
|
|
464
|
+
outcome: "added no tag",
|
|
465
|
+
noun: "chip template"
|
|
466
|
+
});
|
|
306
467
|
/** Collapses one target/Value mutation batch into one final-DOM repair pass. */
|
|
307
468
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileTags());
|
|
308
469
|
#chipRow = new ChipRow({
|
|
309
470
|
directionElement: this.element,
|
|
310
471
|
getItems: () => this.tagTargets,
|
|
311
|
-
getButton: (tag) => tag.
|
|
472
|
+
getButton: (tag) => matchingPart(tag, `button${this.#rows.selector("remove")}`),
|
|
312
473
|
onRemove: (index) => this.#removeAt(index),
|
|
313
474
|
focusAfterEnd: () => this.#focusInput()
|
|
314
475
|
});
|
|
@@ -316,7 +477,7 @@ var TagsInputController = class extends Controller {
|
|
|
316
477
|
#composition = new CompositionTracker();
|
|
317
478
|
/** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
|
|
318
479
|
connect() {
|
|
319
|
-
this.#
|
|
480
|
+
this.#rows.connect();
|
|
320
481
|
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
321
482
|
if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
|
|
322
483
|
const tags = this.#values;
|
|
@@ -363,6 +524,10 @@ var TagsInputController = class extends Controller {
|
|
|
363
524
|
nameValueChanged() {
|
|
364
525
|
this.#reconcile.schedule();
|
|
365
526
|
}
|
|
527
|
+
/** Repoints submitted fields when the owning form changes at runtime. */
|
|
528
|
+
formValueChanged() {
|
|
529
|
+
this.#reconcile.schedule();
|
|
530
|
+
}
|
|
366
531
|
/** Recomputes the full hook when the cap changes at runtime. */
|
|
367
532
|
maxValueChanged() {
|
|
368
533
|
this.#reconcile.schedule();
|
|
@@ -393,7 +558,11 @@ var TagsInputController = class extends Controller {
|
|
|
393
558
|
}
|
|
394
559
|
}
|
|
395
560
|
}
|
|
396
|
-
/**
|
|
561
|
+
/**
|
|
562
|
+
* Validates and adds the current input value as a tag, then clears the input.
|
|
563
|
+
*
|
|
564
|
+
* @stimeoRuntimeOnly `max` and `allowDuplicates` decide whether this one entry is taken.
|
|
565
|
+
*/
|
|
397
566
|
#commitInput() {
|
|
398
567
|
const value = this.inputTarget.value.trim();
|
|
399
568
|
if (value === "") {
|
|
@@ -411,53 +580,31 @@ var TagsInputController = class extends Controller {
|
|
|
411
580
|
if (!this.#appendTag(value)) return;
|
|
412
581
|
this.inputTarget.value = "";
|
|
413
582
|
const tags = this.#values;
|
|
414
|
-
this.#syncState(tags);
|
|
583
|
+
this.#syncState(tags, true);
|
|
415
584
|
this.#tagValues = tags;
|
|
416
585
|
this.#announceTransition(true, value, tags.length);
|
|
417
586
|
this.dispatch("change", { detail: { tags } });
|
|
418
587
|
}
|
|
419
|
-
/** Builds one chip from the template and appends it to the tag list. */
|
|
420
|
-
#appendTag(value) {
|
|
421
|
-
if (!this.hasTagsTarget) return false;
|
|
422
|
-
if (!this.hasTagTemplateTarget) return this.#warnTemplate('a "tagTemplate" target');
|
|
423
|
-
const fragment = this.tagTemplateTarget.content.cloneNode(true);
|
|
424
|
-
const tag = fragment.querySelector('[data-stimeo--tags-input-target~="tag"]');
|
|
425
|
-
const label = fragment.querySelector('[data-stimeo--tags-input-target~="label"]');
|
|
426
|
-
const button = fragment.querySelector(
|
|
427
|
-
'button[data-stimeo--tags-input-target~="remove"]'
|
|
428
|
-
);
|
|
429
|
-
const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
|
|
430
|
-
if (!tag) return this.#warnTemplate('a "tag" target');
|
|
431
|
-
if (!label) return this.#warnTemplate('a "label" target');
|
|
432
|
-
if (!button) return this.#warnTemplate('a "remove" target <button>');
|
|
433
|
-
if (removeName === "") {
|
|
434
|
-
return this.#warnTemplate('a non-empty aria-label on its "remove" target');
|
|
435
|
-
}
|
|
436
|
-
tag.dataset.value = value;
|
|
437
|
-
label.textContent = value;
|
|
438
|
-
button.setAttribute("aria-label", fillTemplate(removeName, { label: value, value }));
|
|
439
|
-
button.tabIndex = -1;
|
|
440
|
-
this.tagsTarget.appendChild(fragment);
|
|
441
|
-
return true;
|
|
442
|
-
}
|
|
443
588
|
/**
|
|
444
|
-
*
|
|
589
|
+
* Builds one chip from the template and appends it to the tag list.
|
|
445
590
|
*
|
|
446
|
-
*
|
|
447
|
-
* hidden fields, the announcement, or the events changes
|
|
448
|
-
*
|
|
449
|
-
* the Inspector cannot see statically (a name that renders empty from a
|
|
450
|
-
* missing translation, a server-rendered template) would have no diagnostic
|
|
451
|
-
* anywhere.
|
|
591
|
+
* A refused row leaves the commit a no-op — nothing about the input, the tag
|
|
592
|
+
* set, the hidden fields, the announcement, or the events changes — and the
|
|
593
|
+
* row reports why on the console once per connection.
|
|
452
594
|
*/
|
|
453
|
-
#
|
|
454
|
-
if (!this
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
);
|
|
595
|
+
#appendTag(value) {
|
|
596
|
+
if (!this.hasTagsTarget) return false;
|
|
597
|
+
if (!this.hasTagTemplateTarget) {
|
|
598
|
+
this.#rows.report('a "tagTemplate" target');
|
|
599
|
+
return false;
|
|
459
600
|
}
|
|
460
|
-
|
|
601
|
+
const row = this.#rows.instantiate(this.tagTemplateTarget, { label: value, value });
|
|
602
|
+
if (!row) return false;
|
|
603
|
+
row.root.dataset.value = value;
|
|
604
|
+
writeLabel(row.slots.label, value);
|
|
605
|
+
row.button.tabIndex = -1;
|
|
606
|
+
this.tagsTarget.appendChild(row.root);
|
|
607
|
+
return true;
|
|
461
608
|
}
|
|
462
609
|
/** Removes the tag at `index`, then applies the interaction-origin focus policy. */
|
|
463
610
|
#removeAt(index, focus = "neighbor") {
|
|
@@ -466,7 +613,7 @@ var TagsInputController = class extends Controller {
|
|
|
466
613
|
const value = tag.dataset.value ?? "";
|
|
467
614
|
tag.remove();
|
|
468
615
|
const tags = this.#values;
|
|
469
|
-
this.#syncState(tags);
|
|
616
|
+
this.#syncState(tags, true);
|
|
470
617
|
this.#tagValues = tags;
|
|
471
618
|
this.#announceTransition(false, value, tags.length);
|
|
472
619
|
this.dispatch("change", { detail: { tags } });
|
|
@@ -480,21 +627,20 @@ var TagsInputController = class extends Controller {
|
|
|
480
627
|
#focusInput() {
|
|
481
628
|
if (this.hasInputTarget) this.inputTarget.focus();
|
|
482
629
|
}
|
|
483
|
-
/**
|
|
484
|
-
|
|
630
|
+
/**
|
|
631
|
+
* Rebuilds the hidden form fields, the `full` flag, and the roving Tab stop.
|
|
632
|
+
*
|
|
633
|
+
* @stimeoRenderRoot
|
|
634
|
+
*/
|
|
635
|
+
#syncState(values, notify = false) {
|
|
485
636
|
if (this.hasFieldsTarget) {
|
|
486
|
-
this.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
input.name = this.nameValue;
|
|
491
|
-
input.value = value;
|
|
492
|
-
return input;
|
|
493
|
-
})
|
|
494
|
-
);
|
|
637
|
+
const options = { name: this.nameValue, form: this.formValue };
|
|
638
|
+
if (writeFields(this.fieldsTarget, values, options) && notify) {
|
|
639
|
+
commitField(this.fieldsTarget);
|
|
640
|
+
}
|
|
495
641
|
}
|
|
496
642
|
const full = this.maxValue > 0 && values.length >= this.maxValue;
|
|
497
|
-
this.element.toggleAttribute(
|
|
643
|
+
this.element.toggleAttribute(`data-${this.identifier}-full`, full);
|
|
498
644
|
this.#chipRow.ensureTabStop();
|
|
499
645
|
}
|
|
500
646
|
/** Repairs derived state after DOM/Turbo changes and reports a changed tag order. */
|
|
@@ -507,7 +653,11 @@ var TagsInputController = class extends Controller {
|
|
|
507
653
|
this.#tagValues = tags;
|
|
508
654
|
if (changed) this.dispatch("reconcile", { detail: { tags } });
|
|
509
655
|
}
|
|
510
|
-
/**
|
|
656
|
+
/**
|
|
657
|
+
* Sends one localized tag transition through the page's shared announcer.
|
|
658
|
+
*
|
|
659
|
+
* @stimeoRuntimeOnly The texts word the one announcement of this change.
|
|
660
|
+
*/
|
|
511
661
|
#announceTransition(added, value, count) {
|
|
512
662
|
const template = added ? this.announceTextValue : this.announceRemovedTextValue;
|
|
513
663
|
announce(fillTemplate(template, { label: value, value, count }));
|
|
@@ -8,10 +8,15 @@ var LayoutObserver = class {
|
|
|
8
8
|
#resizeObserverFactory;
|
|
9
9
|
#resizeObserver = null;
|
|
10
10
|
#observingViewport = false;
|
|
11
|
+
#loadContainer = null;
|
|
11
12
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
12
13
|
#handleViewportResize = () => {
|
|
13
14
|
this.#callback();
|
|
14
15
|
};
|
|
16
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
17
|
+
#handleDescendantLoad = () => {
|
|
18
|
+
this.#callback();
|
|
19
|
+
};
|
|
15
20
|
constructor(callback, options = {}) {
|
|
16
21
|
this.#callback = callback;
|
|
17
22
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -47,14 +52,35 @@ var LayoutObserver = class {
|
|
|
47
52
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
48
53
|
}
|
|
49
54
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
55
|
+
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
56
|
+
* frame settling changes the box it sits in, and it measures as zero high until
|
|
57
|
+
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
58
|
+
* on the container itself and nothing the caller spells.
|
|
59
|
+
*
|
|
60
|
+
* **One container at a time.** A further call moves the observation, so a widget
|
|
61
|
+
* whose content element is swapped at runtime releases the element it let go by
|
|
62
|
+
* naming the new one — there is no second place for the release to drift from.
|
|
63
|
+
*/
|
|
64
|
+
observeDescendantLoads(container) {
|
|
65
|
+
this.unobserveDescendantLoads();
|
|
66
|
+
this.#loadContainer = container;
|
|
67
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
68
|
+
}
|
|
69
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
70
|
+
unobserveDescendantLoads() {
|
|
71
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
72
|
+
this.#loadContainer = null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
76
|
+
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
77
|
+
* this from a controller's `disconnect()`.
|
|
53
78
|
*/
|
|
54
79
|
disconnect() {
|
|
55
80
|
this.#resizeObserver?.disconnect();
|
|
56
81
|
this.#resizeObserver = null;
|
|
57
82
|
this.unobserveViewport();
|
|
83
|
+
this.unobserveDescendantLoads();
|
|
58
84
|
}
|
|
59
85
|
};
|
|
60
86
|
|
|
@@ -113,20 +113,20 @@ var ThemeController = class extends Controller {
|
|
|
113
113
|
target: { type: String, default: DEFAULT_TARGET }
|
|
114
114
|
};
|
|
115
115
|
static actions = ["set", "toggle"];
|
|
116
|
-
static events = ["change"];
|
|
116
|
+
static events = ["change", "reconcile"];
|
|
117
117
|
/** The OS dark-mode query, watched so `system` tracks live changes. */
|
|
118
118
|
#media = null;
|
|
119
|
-
/** Gate for the target callbacks, which Stimulus runs before `connect()`. */
|
|
119
|
+
/** Gate for the target and `mode` callbacks, which Stimulus runs before `connect()`. */
|
|
120
120
|
#connected = false;
|
|
121
121
|
/** The `target` declaration after validation; the default when unparsable. */
|
|
122
122
|
#targetSelector = DEFAULT_TARGET;
|
|
123
123
|
/** Owns the single Tab stop across the option set (APG radiogroup). */
|
|
124
124
|
#roving = new RovingTabindex(() => this.optionTargets);
|
|
125
125
|
/**
|
|
126
|
-
* The pair last reported, so a move can be told from a
|
|
127
|
-
* readable after the fact — assigning the Value updates the
|
|
128
|
-
* comparison, and the OS query has already flipped by the time it
|
|
129
|
-
* so what was reported has to be kept rather than recomputed.
|
|
126
|
+
* The pair on screen as last reported or re-seeded, so a move can be told from a
|
|
127
|
+
* repeat. Neither side is readable after the fact — assigning the Value updates the
|
|
128
|
+
* mode before any comparison, and the OS query has already flipped by the time it
|
|
129
|
+
* notifies — so what was reported has to be kept rather than recomputed.
|
|
130
130
|
*/
|
|
131
131
|
#published = {
|
|
132
132
|
mode: DEFAULT_MODE,
|
|
@@ -189,13 +189,32 @@ var ThemeController = class extends Controller {
|
|
|
189
189
|
targetValueChanged() {
|
|
190
190
|
this.#targetSelector = validSelector(this.element, this.targetValue, DEFAULT_TARGET);
|
|
191
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Re-renders for a `mode` declaration changed after connect — a Turbo morph
|
|
194
|
+
* re-rendering the server's markup over the live element, or a script.
|
|
195
|
+
*
|
|
196
|
+
* A stored choice outranks the declaration: a Value that disagrees with it is written
|
|
197
|
+
* back to it, so a morph that brings the server's default never overrides what the
|
|
198
|
+
* user saved. Without one, the declaration is applied. Either way storage is left
|
|
199
|
+
* alone, and a pair on screen that moved is reported as `reconcile`, not `change` —
|
|
200
|
+
* the page moved the declaration, not a selection. The reported pair is re-seeded
|
|
201
|
+
* first, so the next selection is compared with what is on screen.
|
|
202
|
+
*/
|
|
203
|
+
modeValueChanged() {
|
|
204
|
+
if (!this.#connected) return;
|
|
205
|
+
if (this.modeValue === this.#published.mode) return;
|
|
206
|
+
const stored = this.#readStored();
|
|
207
|
+
if (stored !== null && this.modeValue !== stored) this.modeValue = stored;
|
|
208
|
+
this.#applyTheme();
|
|
209
|
+
this.#reconcileControls();
|
|
210
|
+
}
|
|
192
211
|
/** Re-derives the single Tab stop and ARIA for an option set that changed. */
|
|
193
212
|
optionTargetConnected() {
|
|
194
|
-
if (this.#connected) this.#
|
|
213
|
+
if (this.#connected) this.#reconcileControls();
|
|
195
214
|
}
|
|
196
215
|
/** Re-derives them again when an option leaves, so a Tab stop always remains. */
|
|
197
216
|
optionTargetDisconnected() {
|
|
198
|
-
if (this.#connected) this.#
|
|
217
|
+
if (this.#connected) this.#reconcileControls();
|
|
199
218
|
}
|
|
200
219
|
/**
|
|
201
220
|
* Selects the mode the activated option declares.
|
|
@@ -227,18 +246,45 @@ var ThemeController = class extends Controller {
|
|
|
227
246
|
#commit() {
|
|
228
247
|
this.#applyTheme();
|
|
229
248
|
this.#syncControls();
|
|
249
|
+
const moved = this.#settle();
|
|
250
|
+
if (moved) this.dispatch("change", { detail: moved });
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Re-derives the controls from the mode on screen for a change the page made — a
|
|
254
|
+
* `mode` declaration or an option set — and reports a pair that moved since the
|
|
255
|
+
* last report as `reconcile`. An option coming or going moves neither half of the
|
|
256
|
+
* pair, so of these changes only a declaration reports.
|
|
257
|
+
*/
|
|
258
|
+
#reconcileControls() {
|
|
259
|
+
this.#syncControls();
|
|
260
|
+
const moved = this.#settle();
|
|
261
|
+
if (moved) this.dispatch("reconcile", { detail: moved });
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Takes the pair on screen as the reported one and returns it when it differs
|
|
265
|
+
* from the one reported before, or `null` when neither half moved.
|
|
266
|
+
*
|
|
267
|
+
* The baseline moves before anything is dispatched, so a listener that selects
|
|
268
|
+
* another mode is measured from the pair it was just told about. What is
|
|
269
|
+
* returned is a copy: the baseline has to survive a listener that writes to what
|
|
270
|
+
* it was handed, or the next unchanged operation reads as a move.
|
|
271
|
+
*/
|
|
272
|
+
#settle() {
|
|
230
273
|
const next = this.#current;
|
|
231
274
|
const last = this.#published;
|
|
232
275
|
this.#published = next;
|
|
233
|
-
if (last.mode
|
|
234
|
-
|
|
235
|
-
}
|
|
276
|
+
if (last.mode === next.mode && last.resolved === next.resolved) return null;
|
|
277
|
+
return { ...next };
|
|
236
278
|
}
|
|
237
|
-
/** The pair the `change`
|
|
279
|
+
/** The pair the `change` and `reconcile` details carry, read from current state. */
|
|
238
280
|
get #current() {
|
|
239
281
|
return { mode: this.#mode, resolved: this.#resolved() };
|
|
240
282
|
}
|
|
241
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* Writes `data-theme` + `color-scheme` (the resolved theme) onto the target.
|
|
285
|
+
*
|
|
286
|
+
* @stimeoRenderRoot
|
|
287
|
+
*/
|
|
242
288
|
#applyTheme() {
|
|
243
289
|
const root = this.#targetElement();
|
|
244
290
|
if (!root) return;
|
|
@@ -246,7 +292,11 @@ var ThemeController = class extends Controller {
|
|
|
246
292
|
root.setAttribute("data-theme", resolved);
|
|
247
293
|
root.style.setProperty("color-scheme", resolved);
|
|
248
294
|
}
|
|
249
|
-
/**
|
|
295
|
+
/**
|
|
296
|
+
* Keeps the radiogroup (aria-checked + roving tabindex) or toggle (aria-pressed) in sync.
|
|
297
|
+
*
|
|
298
|
+
* @stimeoRenderRoot
|
|
299
|
+
*/
|
|
250
300
|
#syncControls() {
|
|
251
301
|
const options = this.optionTargets;
|
|
252
302
|
if (options.length > 0) {
|
|
@@ -18,6 +18,16 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
18
18
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// src/utils/field_mirror.ts
|
|
22
|
+
function writeField(field, value) {
|
|
23
|
+
if (field.value === value) return false;
|
|
24
|
+
field.value = value;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
function commitField(target) {
|
|
28
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
// src/utils/microtask_coalescer.ts
|
|
22
32
|
var MicrotaskCoalescer = class {
|
|
23
33
|
#run;
|
|
@@ -51,6 +61,11 @@ var MicrotaskCoalescer = class {
|
|
|
51
61
|
}
|
|
52
62
|
};
|
|
53
63
|
|
|
64
|
+
// src/utils/target_selector.ts
|
|
65
|
+
function targetSelector(identifier, name) {
|
|
66
|
+
return `[data-${identifier}-target~="${name}"]`;
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
// src/controllers/time_picker_controller.ts
|
|
55
70
|
var AM = 0;
|
|
56
71
|
var PM = 1;
|
|
@@ -68,7 +83,10 @@ var TimePickerController = class extends Controller {
|
|
|
68
83
|
};
|
|
69
84
|
static actions = ["onKeydown"];
|
|
70
85
|
static events = ["change", "reconcile"];
|
|
71
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Collapses target, Value, and retained-attribute morphs into one render that
|
|
88
|
+
* dispatches no `change`.
|
|
89
|
+
*/
|
|
72
90
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
73
91
|
/** Canonical state; the displayed hour and meridiem are derived from this value. */
|
|
74
92
|
#state = { hour: 0, minute: 0, second: 0 };
|
|
@@ -133,7 +151,7 @@ var TimePickerController = class extends Controller {
|
|
|
133
151
|
onKeydown(event) {
|
|
134
152
|
if (isReservedArrowChord(event)) return;
|
|
135
153
|
const segment = event.target?.closest(
|
|
136
|
-
"
|
|
154
|
+
targetSelector(this.identifier, "segment")
|
|
137
155
|
);
|
|
138
156
|
if (!segment || !this.segmentTargets.includes(segment)) return;
|
|
139
157
|
const kind = this.#kindOf(segment);
|
|
@@ -180,7 +198,7 @@ var TimePickerController = class extends Controller {
|
|
|
180
198
|
/** Clears direct-entry state when Tab, Shift+Tab, or pointer focus leaves a segment. */
|
|
181
199
|
#onFocusOut = (event) => {
|
|
182
200
|
const segment = event.target?.closest(
|
|
183
|
-
"
|
|
201
|
+
targetSelector(this.identifier, "segment")
|
|
184
202
|
);
|
|
185
203
|
if (!segment || !this.segmentTargets.includes(segment)) return;
|
|
186
204
|
this.#clearTypeBuffer();
|
|
@@ -316,8 +334,7 @@ var TimePickerController = class extends Controller {
|
|
|
316
334
|
#commitRender() {
|
|
317
335
|
this.#renderSegments();
|
|
318
336
|
const value = this.#composedValue;
|
|
319
|
-
|
|
320
|
-
if (fieldChanged) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
|
|
337
|
+
if (this.#writeField(value)) commitField(this.fieldTarget);
|
|
321
338
|
if (value !== this.#lastValue) this.dispatch("change", { detail: { value } });
|
|
322
339
|
this.#lastValue = value;
|
|
323
340
|
}
|
|
@@ -336,9 +353,7 @@ var TimePickerController = class extends Controller {
|
|
|
336
353
|
}
|
|
337
354
|
/** Writes a composed value to the optional form field and reports whether it changed. */
|
|
338
355
|
#writeField(value) {
|
|
339
|
-
|
|
340
|
-
this.fieldTarget.value = value;
|
|
341
|
-
return true;
|
|
356
|
+
return this.hasFieldTarget && writeField(this.fieldTarget, value);
|
|
342
357
|
}
|
|
343
358
|
/** The canonical form value composed as `HH:MM[:SS]`. */
|
|
344
359
|
get #composedValue() {
|