stimeo-ui 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +209 -0
- data/README.md +128 -1
- data/dist/cable/index.js +392 -417
- data/dist/controllers/accordion_controller.js +228 -28
- data/dist/controllers/alert_dialog_controller.js +862 -221
- data/dist/controllers/announcer_controller.js +135 -191
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +295 -110
- data/dist/controllers/avatar_controller.js +172 -106
- data/dist/controllers/breadcrumb_controller.js +0 -129
- data/dist/controllers/bulk_select_controller.js +264 -53
- data/dist/controllers/calendar_controller.js +346 -180
- data/dist/controllers/carousel_controller.js +587 -300
- data/dist/controllers/character_counter_controller.js +271 -143
- data/dist/controllers/checkbox_controller.js +77 -75
- data/dist/controllers/clipboard_controller.js +187 -108
- data/dist/controllers/collapsible_controller.js +374 -108
- data/dist/controllers/color_picker_controller.js +183 -127
- data/dist/controllers/combobox_controller.js +426 -109
- data/dist/controllers/command_palette_controller.js +994 -350
- data/dist/controllers/conditional_fields_controller.js +212 -125
- data/dist/controllers/confirm_controller.js +939 -245
- data/dist/controllers/context_menu_controller.js +275 -127
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +235 -97
- data/dist/controllers/currency_input_controller.js +241 -118
- data/dist/controllers/data_grid_controller.js +229 -135
- data/dist/controllers/date_range_picker_controller.js +359 -165
- data/dist/controllers/dialog_controller.js +887 -225
- data/dist/controllers/direct_upload_controller.js +80 -142
- data/dist/controllers/dirty_form_controller.js +26 -47
- data/dist/controllers/dismissible_controller.js +175 -20
- data/dist/controllers/drawer_controller.js +963 -336
- data/dist/controllers/dropdown_controller.js +342 -85
- data/dist/controllers/editable_controller.js +182 -58
- data/dist/controllers/empty_state_controller.js +67 -53
- data/dist/controllers/file_dropzone_controller.js +426 -229
- data/dist/controllers/filter_controller.js +78 -36
- data/dist/controllers/flash_controller.js +328 -211
- data/dist/controllers/focus_controller.js +654 -239
- data/dist/controllers/form_field_controller.js +153 -136
- data/dist/controllers/form_validation_controller.js +29 -96
- data/dist/controllers/frame_loading_controller.js +263 -198
- data/dist/controllers/highlight_controller.js +119 -70
- data/dist/controllers/hover_card_controller.js +335 -118
- data/dist/controllers/idle_controller.js +316 -65
- data/dist/controllers/input_mask_controller.js +116 -61
- data/dist/controllers/intersection_controller.js +147 -103
- data/dist/controllers/lazy_frame_controller.js +9 -56
- data/dist/controllers/listbox_controller.js +413 -153
- data/dist/controllers/local_time_controller.js +54 -64
- data/dist/controllers/masonry_controller.js +70 -95
- data/dist/controllers/menu_controller.js +254 -167
- data/dist/controllers/menubar_controller.js +206 -331
- data/dist/controllers/meter_controller.js +159 -71
- data/dist/controllers/multi_select_controller.js +797 -388
- data/dist/controllers/navigation_menu_controller.js +156 -231
- data/dist/controllers/nested_form_controller.js +373 -174
- data/dist/controllers/network_status_controller.js +241 -45
- data/dist/controllers/number_input_controller.js +387 -227
- data/dist/controllers/optimistic_controller.js +28 -75
- data/dist/controllers/otp_controller.js +344 -196
- data/dist/controllers/overflow_indicator_controller.js +167 -93
- data/dist/controllers/overflow_menu_controller.js +408 -277
- data/dist/controllers/pagination_controller.js +342 -120
- data/dist/controllers/password_reveal_controller.js +424 -99
- data/dist/controllers/password_strength_controller.js +99 -153
- data/dist/controllers/persist_controller.js +252 -96
- data/dist/controllers/pointer_drag_controller.js +263 -164
- data/dist/controllers/popover_controller.js +267 -94
- data/dist/controllers/portal_controller.js +209 -72
- data/dist/controllers/preview_guard_controller.js +243 -112
- data/dist/controllers/progress_controller.js +132 -59
- data/dist/controllers/radio_group_controller.js +138 -133
- data/dist/controllers/range_slider_controller.js +245 -112
- data/dist/controllers/rating_controller.js +429 -194
- data/dist/controllers/read_more_controller.js +185 -47
- data/dist/controllers/reading_progress_controller.js +147 -135
- data/dist/controllers/relative_time_controller.js +145 -110
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- data/dist/controllers/resizable_controller.js +294 -118
- data/dist/controllers/roving_controller.js +39 -84
- data/dist/controllers/scroll_area_controller.js +428 -221
- data/dist/controllers/scroll_restore_controller.js +0 -53
- data/dist/controllers/scroll_visibility_controller.js +395 -146
- data/dist/controllers/scrollspy_controller.js +181 -236
- data/dist/controllers/separator_controller.js +288 -132
- data/dist/controllers/sidebar_controller.js +1071 -375
- data/dist/controllers/skeleton_controller.js +248 -92
- data/dist/controllers/slider_controller.js +211 -96
- data/dist/controllers/smart_sticky_header_controller.js +163 -54
- data/dist/controllers/sortable_controller.js +13 -110
- data/dist/controllers/spinner_controller.js +277 -161
- data/dist/controllers/step_indicator_controller.js +107 -53
- data/dist/controllers/stepper_controller.js +171 -48
- data/dist/controllers/stick_to_bottom_controller.js +310 -116
- data/dist/controllers/sticky_observer_controller.js +214 -49
- data/dist/controllers/submit_once_controller.js +294 -192
- data/dist/controllers/switch_controller.js +107 -43
- data/dist/controllers/tabs_controller.js +50 -25
- data/dist/controllers/tags_input_controller.js +290 -149
- data/dist/controllers/textarea_autosize_controller.js +71 -58
- data/dist/controllers/theme_controller.js +148 -80
- data/dist/controllers/time_picker_controller.js +162 -81
- data/dist/controllers/toast_controller.js +365 -193
- data/dist/controllers/toggle_group_controller.js +194 -99
- data/dist/controllers/toolbar_controller.js +92 -90
- data/dist/controllers/tooltip_controller.js +185 -125
- data/dist/controllers/transition_controller.js +123 -115
- data/dist/controllers/tree_view_controller.js +248 -290
- data/dist/index.js +8649 -8572
- data/dist/positioning/index.js +148 -45
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
// src/controllers/tags_input_controller.ts
|
|
4
|
-
|
|
5
|
-
// src/utils/announce.ts
|
|
6
3
|
function announce(message, options = {}) {
|
|
7
4
|
const text = message.trim();
|
|
8
5
|
if (text.length === 0) return;
|
|
@@ -19,12 +16,10 @@ function fillTemplate(template, values) {
|
|
|
19
16
|
});
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
// src/utils/logical_scroll.ts
|
|
23
19
|
function isRtl(element) {
|
|
24
20
|
return window.getComputedStyle(element).direction === "rtl";
|
|
25
21
|
}
|
|
26
22
|
|
|
27
|
-
// src/utils/arrow_step.ts
|
|
28
23
|
function logicalArrowKey(key, element) {
|
|
29
24
|
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
30
25
|
if (!isRtl(element)) return key;
|
|
@@ -35,31 +30,32 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
35
30
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
36
31
|
}
|
|
37
32
|
|
|
38
|
-
|
|
33
|
+
function matchingPart(root, selector) {
|
|
34
|
+
return root.matches(selector) ? root : root.querySelector(selector);
|
|
35
|
+
}
|
|
36
|
+
function writeLabel(slot, text) {
|
|
37
|
+
if (slot.childElementCount === 0) {
|
|
38
|
+
slot.textContent = text;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let first = null;
|
|
42
|
+
for (const node of Array.from(slot.childNodes)) {
|
|
43
|
+
if (node.nodeType !== Node.TEXT_NODE) continue;
|
|
44
|
+
if (first) node.remove();
|
|
45
|
+
else first = node;
|
|
46
|
+
}
|
|
47
|
+
if (first) first.nodeValue = text;
|
|
48
|
+
else slot.prepend(text);
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
var RovingTabindex = class {
|
|
40
|
-
/** Returns the current ordered item elements; called on every operation. */
|
|
41
52
|
#getItems;
|
|
42
|
-
/**
|
|
43
|
-
* @param getItems - Returns the current ordered item elements. Called on every
|
|
44
|
-
* operation so the live target list is always used.
|
|
45
|
-
*/
|
|
46
53
|
constructor(getItems) {
|
|
47
54
|
this.#getItems = getItems;
|
|
48
55
|
}
|
|
49
|
-
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
50
56
|
get activeIndex() {
|
|
51
57
|
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
52
58
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
55
|
-
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
56
|
-
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
57
|
-
* "nothing is currently tabbable".
|
|
58
|
-
*
|
|
59
|
-
* @param index - Position of the item to make tabbable.
|
|
60
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
61
|
-
* and `items` to reuse an event-scoped collection snapshot.
|
|
62
|
-
*/
|
|
63
59
|
setActive(index, options = {}) {
|
|
64
60
|
const { focus = false } = options;
|
|
65
61
|
const items = options.items ?? this.#getItems();
|
|
@@ -70,7 +66,6 @@ var RovingTabindex = class {
|
|
|
70
66
|
}
|
|
71
67
|
};
|
|
72
68
|
|
|
73
|
-
// src/utils/chip_row.ts
|
|
74
69
|
var ChipRow = class {
|
|
75
70
|
#directionElement;
|
|
76
71
|
#getItems;
|
|
@@ -82,14 +77,10 @@ var ChipRow = class {
|
|
|
82
77
|
constructor(options) {
|
|
83
78
|
this.#directionElement = options.directionElement;
|
|
84
79
|
this.#getItems = options.getItems;
|
|
85
|
-
this.#getButton = options.getButton ?? ((item) => item
|
|
80
|
+
this.#getButton = options.getButton ?? ((item) => matchingPart(item, "button"));
|
|
86
81
|
this.#onRemove = options.onRemove;
|
|
87
82
|
this.#focusAfterEnd = options.focusAfterEnd;
|
|
88
83
|
}
|
|
89
|
-
/**
|
|
90
|
-
* Binds delegation to `container`; any row currently bound is released first.
|
|
91
|
-
* Idempotent for the same node, so target callbacks may call it freely.
|
|
92
|
-
*/
|
|
93
84
|
connect(container) {
|
|
94
85
|
if (this.#container === container) return;
|
|
95
86
|
this.disconnect();
|
|
@@ -97,10 +88,6 @@ var ChipRow = class {
|
|
|
97
88
|
container.addEventListener("click", this.#onClick);
|
|
98
89
|
container.addEventListener("keydown", this.#onKeydown);
|
|
99
90
|
}
|
|
100
|
-
/**
|
|
101
|
-
* Releases the current row. When `container` is supplied, a stale disconnect
|
|
102
|
-
* callback cannot tear listeners off a newer replacement target.
|
|
103
|
-
*/
|
|
104
91
|
disconnect(container) {
|
|
105
92
|
const current = this.#container;
|
|
106
93
|
if (!current || container !== void 0 && current !== container) return;
|
|
@@ -108,19 +95,15 @@ var ChipRow = class {
|
|
|
108
95
|
current.removeEventListener("keydown", this.#onKeydown);
|
|
109
96
|
this.#container = null;
|
|
110
97
|
}
|
|
111
|
-
/** Current remove buttons, at most one consumer-resolved button per chip item. */
|
|
112
98
|
get buttons() {
|
|
113
99
|
return this.#entries.map(({ button }) => button);
|
|
114
100
|
}
|
|
115
|
-
/** Number of currently navigable remove buttons. */
|
|
116
101
|
get length() {
|
|
117
102
|
return this.buttons.length;
|
|
118
103
|
}
|
|
119
|
-
/** Declared-item index of the last navigable chip, or `-1` for an empty row. */
|
|
120
104
|
get lastIndex() {
|
|
121
105
|
return this.#entries.at(-1)?.itemIndex ?? -1;
|
|
122
106
|
}
|
|
123
|
-
/** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
|
|
124
107
|
ensureTabStop() {
|
|
125
108
|
const buttons = this.#entries.map(({ button }) => button);
|
|
126
109
|
const active = buttons.findIndex((button) => button.tabIndex === 0);
|
|
@@ -129,7 +112,6 @@ var ChipRow = class {
|
|
|
129
112
|
this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
|
|
130
113
|
}
|
|
131
114
|
}
|
|
132
|
-
/** Focuses the last chip, returning false for an empty row. */
|
|
133
115
|
focusLast() {
|
|
134
116
|
const buttons = this.#entries.map(({ button }) => button);
|
|
135
117
|
const last = buttons.length - 1;
|
|
@@ -137,10 +119,6 @@ var ChipRow = class {
|
|
|
137
119
|
this.#roving.setActive(last, { focus: true, items: buttons });
|
|
138
120
|
return true;
|
|
139
121
|
}
|
|
140
|
-
/**
|
|
141
|
-
* Focuses the chip that followed a removed index, or the new last chip.
|
|
142
|
-
* Returns false when removal emptied the row so the consumer can rescue focus.
|
|
143
|
-
*/
|
|
144
122
|
focusAfterRemoval(index) {
|
|
145
123
|
const entries = this.#entries;
|
|
146
124
|
if (entries.length === 0) return false;
|
|
@@ -151,14 +129,12 @@ var ChipRow = class {
|
|
|
151
129
|
});
|
|
152
130
|
return true;
|
|
153
131
|
}
|
|
154
|
-
/** Current remove-button entries, retaining each button's declared item index. */
|
|
155
132
|
get #entries() {
|
|
156
133
|
return this.#getItems().flatMap((item, itemIndex) => {
|
|
157
134
|
const button = this.#getButton(item);
|
|
158
135
|
return button ? [{ button, itemIndex }] : [];
|
|
159
136
|
});
|
|
160
137
|
}
|
|
161
|
-
/** Resolves a delegated event to the remove button owned by a declared chip. */
|
|
162
138
|
#entry(event) {
|
|
163
139
|
const target = event.target;
|
|
164
140
|
if (!(target instanceof Element)) return null;
|
|
@@ -169,13 +145,11 @@ var ChipRow = class {
|
|
|
169
145
|
const entry = entries[buttonIndex];
|
|
170
146
|
return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
|
|
171
147
|
}
|
|
172
|
-
/** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
|
|
173
148
|
#onClick = (event) => {
|
|
174
149
|
const entry = this.#entry(event);
|
|
175
150
|
if (!entry) return;
|
|
176
151
|
this.#onRemove(entry.itemIndex);
|
|
177
152
|
};
|
|
178
|
-
/** Applies the shared logical-arrow and removal policy within the chip row. */
|
|
179
153
|
#onKeydown = (event) => {
|
|
180
154
|
if (event.defaultPrevented || isReservedArrowChord(event)) return;
|
|
181
155
|
const entry = this.#entry(event);
|
|
@@ -206,74 +180,121 @@ var ChipRow = class {
|
|
|
206
180
|
};
|
|
207
181
|
};
|
|
208
182
|
|
|
209
|
-
|
|
183
|
+
var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
184
|
+
"insertCompositionText",
|
|
185
|
+
"insertFromComposition",
|
|
186
|
+
"deleteCompositionText",
|
|
187
|
+
"deleteByComposition"
|
|
188
|
+
]);
|
|
210
189
|
var CompositionTracker = class {
|
|
211
190
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
212
191
|
#activeTargets = /* @__PURE__ */ new Set();
|
|
213
192
|
#onStart;
|
|
214
193
|
#onEnd;
|
|
194
|
+
#confirmedTarget = null;
|
|
215
195
|
constructor(options = {}) {
|
|
216
196
|
this.#onStart = options.onStart;
|
|
217
197
|
this.#onEnd = options.onEnd;
|
|
218
198
|
}
|
|
219
|
-
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
220
199
|
observe(target) {
|
|
221
200
|
if (this.#observedTargets.has(target)) return;
|
|
222
201
|
target.addEventListener("compositionstart", this.#handleStart);
|
|
223
202
|
target.addEventListener("compositionend", this.#handleEnd);
|
|
203
|
+
target.addEventListener("keydown", this.#handleKeydown);
|
|
224
204
|
this.#observedTargets.add(target);
|
|
225
205
|
}
|
|
226
|
-
/** Stops tracking one target and clears any active composition it owned. */
|
|
227
206
|
unobserve(target) {
|
|
228
207
|
if (!this.#observedTargets.delete(target)) return;
|
|
229
208
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
230
209
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
210
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
231
211
|
this.#activeTargets.delete(target);
|
|
212
|
+
if (this.#confirmedTarget === target) this.#confirmedTarget = null;
|
|
232
213
|
}
|
|
233
|
-
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
234
214
|
disconnect() {
|
|
235
215
|
for (const target of this.#observedTargets) {
|
|
236
216
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
237
217
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
218
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
238
219
|
}
|
|
239
220
|
this.#observedTargets.clear();
|
|
240
221
|
this.#activeTargets.clear();
|
|
222
|
+
this.#confirmedTarget = null;
|
|
241
223
|
}
|
|
242
|
-
/** True when lifecycle tracking or the current event reports composition. */
|
|
243
224
|
isComposing(event) {
|
|
244
225
|
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
245
226
|
}
|
|
227
|
+
consumesConfirmedInput(event) {
|
|
228
|
+
const confirmed = this.#confirmedTarget;
|
|
229
|
+
this.#confirmedTarget = null;
|
|
230
|
+
if (confirmed === null || confirmed !== event.target) return false;
|
|
231
|
+
const inputType = event.inputType;
|
|
232
|
+
return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
|
|
233
|
+
}
|
|
246
234
|
#handleStart = (event) => {
|
|
235
|
+
this.#confirmedTarget = null;
|
|
247
236
|
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
248
237
|
this.#onStart?.(event);
|
|
249
238
|
};
|
|
250
239
|
#handleEnd = (event) => {
|
|
251
240
|
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
241
|
+
this.#confirmedTarget = event.target;
|
|
252
242
|
this.#onEnd?.(event);
|
|
253
243
|
};
|
|
244
|
+
#handleKeydown = () => {
|
|
245
|
+
this.#confirmedTarget = null;
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
function writeFields(container, values, { name, form = "" }) {
|
|
250
|
+
const current = [...container.children];
|
|
251
|
+
const submits = current.length === values.length && current.every(
|
|
252
|
+
(child, index) => child instanceof HTMLInputElement && child.type === "hidden" && child.value === values[index] && child.name === name && (child.getAttribute("form") ?? "") === form
|
|
253
|
+
);
|
|
254
|
+
if (submits) return false;
|
|
255
|
+
container.replaceChildren(
|
|
256
|
+
...values.map((value) => {
|
|
257
|
+
const input = document.createElement("input");
|
|
258
|
+
input.type = "hidden";
|
|
259
|
+
input.name = name;
|
|
260
|
+
input.value = value;
|
|
261
|
+
if (form !== "") input.setAttribute("form", form);
|
|
262
|
+
return input;
|
|
263
|
+
})
|
|
264
|
+
);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
function commitField(target) {
|
|
268
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
var ListenerSet = class {
|
|
272
|
+
#abort = new AbortController();
|
|
273
|
+
add(target, type, handler, options) {
|
|
274
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
275
|
+
}
|
|
276
|
+
dispose() {
|
|
277
|
+
this.#abort.abort();
|
|
278
|
+
this.#abort = new AbortController();
|
|
279
|
+
}
|
|
254
280
|
};
|
|
255
281
|
|
|
256
|
-
// src/utils/microtask_coalescer.ts
|
|
257
282
|
var MicrotaskCoalescer = class {
|
|
258
283
|
#run;
|
|
259
284
|
#queued = false;
|
|
260
285
|
#active = false;
|
|
261
286
|
#generation = 0;
|
|
262
|
-
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
263
287
|
constructor(run) {
|
|
264
288
|
this.#run = run;
|
|
265
289
|
}
|
|
266
|
-
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
267
290
|
activate() {
|
|
268
291
|
this.#active = true;
|
|
269
292
|
}
|
|
270
|
-
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
271
293
|
cancel() {
|
|
272
294
|
this.#active = false;
|
|
273
295
|
this.#queued = false;
|
|
274
296
|
this.#generation += 1;
|
|
275
297
|
}
|
|
276
|
-
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
277
298
|
schedule() {
|
|
278
299
|
if (!this.#active || this.#queued) return;
|
|
279
300
|
this.#queued = true;
|
|
@@ -286,88 +307,235 @@ var MicrotaskCoalescer = class {
|
|
|
286
307
|
}
|
|
287
308
|
};
|
|
288
309
|
|
|
289
|
-
|
|
290
|
-
|
|
310
|
+
var MorphRenderWatcher = class {
|
|
311
|
+
#listeners = new ListenerSet();
|
|
312
|
+
#pass;
|
|
313
|
+
#element = null;
|
|
314
|
+
#onMorph = () => this.#pass.schedule();
|
|
315
|
+
constructor(run) {
|
|
316
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
317
|
+
}
|
|
318
|
+
observe(element) {
|
|
319
|
+
if (this.#element === element) return;
|
|
320
|
+
this.disconnect();
|
|
321
|
+
this.#element = element;
|
|
322
|
+
this.#pass.activate();
|
|
323
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
324
|
+
}
|
|
325
|
+
schedule() {
|
|
326
|
+
this.#pass.schedule();
|
|
327
|
+
}
|
|
328
|
+
disconnect() {
|
|
329
|
+
this.#listeners.dispose();
|
|
330
|
+
this.#pass.cancel();
|
|
331
|
+
this.#element = null;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
var MoveCounter = class {
|
|
336
|
+
#sequence = 0;
|
|
337
|
+
record() {
|
|
338
|
+
this.#sequence += 1;
|
|
339
|
+
return this.#sequence;
|
|
340
|
+
}
|
|
341
|
+
isLatest(token) {
|
|
342
|
+
return token === this.#sequence;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
var NUMBER_BOUNDS = {
|
|
347
|
+
nonNegative: { finite: true, min: 0 }};
|
|
348
|
+
function matchesNumberBounds(value, bounds) {
|
|
349
|
+
if (!Number.isFinite(value)) {
|
|
350
|
+
const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
|
|
351
|
+
if (direction === null) return false;
|
|
352
|
+
if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
|
|
353
|
+
}
|
|
354
|
+
if (bounds.min !== void 0 && value < bounds.min) return false;
|
|
355
|
+
if (bounds.max !== void 0 && value > bounds.max) return false;
|
|
356
|
+
if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
|
|
357
|
+
if (bounds.integer && !Number.isInteger(value)) return false;
|
|
358
|
+
if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function readNumber(raw, fallback, bounds) {
|
|
363
|
+
return matchesNumberBounds(raw, bounds) ? raw : fallback;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
var NumberValueReader = class {
|
|
367
|
+
#lastRejected = /* @__PURE__ */ new Map();
|
|
368
|
+
read(owner, name, raw, fallback, bounds) {
|
|
369
|
+
const resolved = readNumber(raw, fallback, bounds);
|
|
370
|
+
if (matchesNumberBounds(raw, bounds)) {
|
|
371
|
+
this.#lastRejected.delete(name);
|
|
372
|
+
return resolved;
|
|
373
|
+
}
|
|
374
|
+
const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
|
|
375
|
+
const literal = owner.element.getAttribute(attribute);
|
|
376
|
+
if (literal === null) {
|
|
377
|
+
this.#lastRejected.delete(name);
|
|
378
|
+
return resolved;
|
|
379
|
+
}
|
|
380
|
+
if (this.#lastRejected.get(name) !== literal) {
|
|
381
|
+
this.#lastRejected.set(name, literal);
|
|
382
|
+
console.warn(
|
|
383
|
+
`Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return resolved;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
function targetSelector(identifier, name) {
|
|
391
|
+
return `[data-${identifier}-target~="${name}"]`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function cloneTemplateRoot(template) {
|
|
395
|
+
const root = template.content.firstElementChild;
|
|
396
|
+
return root instanceof HTMLElement ? root.cloneNode(true) : null;
|
|
397
|
+
}
|
|
398
|
+
var TemplateRow = class {
|
|
399
|
+
#options;
|
|
400
|
+
#warned = false;
|
|
401
|
+
constructor(options) {
|
|
402
|
+
this.#options = options;
|
|
403
|
+
}
|
|
404
|
+
selector(name) {
|
|
405
|
+
return targetSelector(this.#options.identifier, name);
|
|
406
|
+
}
|
|
407
|
+
connect() {
|
|
408
|
+
this.#warned = false;
|
|
409
|
+
}
|
|
410
|
+
report(missing) {
|
|
411
|
+
return this.#say(`lacks ${missing}`);
|
|
412
|
+
}
|
|
413
|
+
#say(problem) {
|
|
414
|
+
if (this.#warned) return null;
|
|
415
|
+
this.#warned = true;
|
|
416
|
+
const { identifier, outcome, noun } = this.#options;
|
|
417
|
+
console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
instantiate(template, values) {
|
|
421
|
+
const { root: rootName, required, optional, button: buttonName } = this.#options;
|
|
422
|
+
const root = cloneTemplateRoot(template);
|
|
423
|
+
if (!root?.matches(this.selector(rootName))) {
|
|
424
|
+
return this.report(`${this.#article(rootName)} "${rootName}" root`);
|
|
425
|
+
}
|
|
426
|
+
const count = template.content.children.length;
|
|
427
|
+
if (count !== 1) {
|
|
428
|
+
return this.#say(`holds ${count} elements, and the row has to be its only one`);
|
|
429
|
+
}
|
|
430
|
+
const slots = {};
|
|
431
|
+
for (const name2 of required) {
|
|
432
|
+
const part = this.#resolve(root, name2);
|
|
433
|
+
if (!part) return this.report(`${this.#article(name2)} "${name2}" target`);
|
|
434
|
+
slots[name2] = part;
|
|
435
|
+
}
|
|
436
|
+
for (const name2 of optional ?? []) slots[name2] = this.#resolve(root, name2);
|
|
437
|
+
const named = this.#resolve(root, buttonName);
|
|
438
|
+
if (!(named instanceof HTMLButtonElement)) {
|
|
439
|
+
return this.report(`${this.#article(buttonName)} "${buttonName}" target <button>`);
|
|
440
|
+
}
|
|
441
|
+
const button = named;
|
|
442
|
+
const name = button.getAttribute("aria-label")?.trim() ?? "";
|
|
443
|
+
if (name === "") return this.report(`a non-empty aria-label on its "${buttonName}" target`);
|
|
444
|
+
button.setAttribute("aria-label", fillTemplate(name, values));
|
|
445
|
+
return { root, slots, button };
|
|
446
|
+
}
|
|
447
|
+
#resolve(root, name) {
|
|
448
|
+
return matchingPart(root, this.selector(name));
|
|
449
|
+
}
|
|
450
|
+
#article(name) {
|
|
451
|
+
return /^[aeiou]/i.test(name) ? "an" : "a";
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
var TagsInputController = class _TagsInputController extends Controller {
|
|
456
|
+
#numbers = new NumberValueReader();
|
|
457
|
+
#moves = new MoveCounter();
|
|
291
458
|
static targets = ["input", "tags", "tag", "tagTemplate", "label", "remove", "fields"];
|
|
292
459
|
static values = {
|
|
293
460
|
delimiter: { type: String, default: "," },
|
|
294
461
|
max: { type: Number, default: 0 },
|
|
295
462
|
allowDuplicates: { type: Boolean, default: false },
|
|
296
463
|
name: { type: String, default: "tags[]" },
|
|
464
|
+
form: { type: String, default: "" },
|
|
297
465
|
announceText: { type: String, default: "" },
|
|
298
466
|
announceRemovedText: { type: String, default: "" }
|
|
299
467
|
};
|
|
468
|
+
static valueConstraints = {
|
|
469
|
+
max: NUMBER_BOUNDS.nonNegative
|
|
470
|
+
};
|
|
300
471
|
static actions = ["onKeydown"];
|
|
301
472
|
static events = ["change", "reconcile", "reject"];
|
|
302
|
-
/** Last reconciled tag order, separating user edits from DOM/Turbo repair. */
|
|
303
473
|
#tagValues = [];
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
474
|
+
#rows = new TemplateRow({
|
|
475
|
+
identifier: this.identifier,
|
|
476
|
+
root: "tag",
|
|
477
|
+
required: ["label"],
|
|
478
|
+
button: "remove",
|
|
479
|
+
outcome: "added no tag",
|
|
480
|
+
noun: "chip template"
|
|
481
|
+
});
|
|
482
|
+
#reconcile = new MorphRenderWatcher(() => this.#reconcileTags());
|
|
308
483
|
#chipRow = new ChipRow({
|
|
309
484
|
directionElement: this.element,
|
|
310
485
|
getItems: () => this.tagTargets,
|
|
311
|
-
getButton: (tag) => tag.
|
|
486
|
+
getButton: (tag) => matchingPart(tag, `button${this.#rows.selector("remove")}`),
|
|
312
487
|
onRemove: (index) => this.#removeAt(index),
|
|
313
488
|
focusAfterEnd: () => this.#focusInput()
|
|
314
489
|
});
|
|
315
|
-
/** Owns IME lifecycle state for commit-key guarding. */
|
|
316
490
|
#composition = new CompositionTracker();
|
|
317
|
-
/** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
|
|
318
491
|
connect() {
|
|
319
|
-
this.#
|
|
492
|
+
this.#rows.connect();
|
|
320
493
|
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
321
494
|
if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
|
|
322
495
|
const tags = this.#values;
|
|
323
496
|
this.#syncState(tags);
|
|
324
497
|
this.#tagValues = tags;
|
|
325
|
-
this.#reconcile.
|
|
498
|
+
this.#reconcile.observe(this.element);
|
|
326
499
|
}
|
|
327
|
-
/** Releases the delegated listeners so no handler outlives the element. */
|
|
328
500
|
disconnect() {
|
|
329
|
-
this.#reconcile.
|
|
501
|
+
this.#reconcile.disconnect();
|
|
330
502
|
this.#composition.disconnect();
|
|
331
503
|
this.#chipRow.disconnect();
|
|
332
504
|
}
|
|
333
|
-
/** Tracks an input added initially or after connect. */
|
|
334
505
|
inputTargetConnected(input) {
|
|
335
506
|
this.#composition.observe(input);
|
|
336
507
|
}
|
|
337
|
-
/** Removes composition listeners when the active input is replaced or removed. */
|
|
338
508
|
inputTargetDisconnected(input) {
|
|
339
509
|
this.#composition.unobserve(input);
|
|
340
510
|
}
|
|
341
|
-
/** Rebinds delegation and derived state when the tags container is replaced. */
|
|
342
511
|
tagsTargetConnected(_tags) {
|
|
343
512
|
this.#reconcile.schedule();
|
|
344
513
|
}
|
|
345
|
-
/** Releases only the row that disconnected, leaving a newer target intact. */
|
|
346
514
|
tagsTargetDisconnected(tags) {
|
|
347
515
|
this.#chipRow.disconnect(tags);
|
|
348
516
|
this.#reconcile.schedule();
|
|
349
517
|
}
|
|
350
|
-
/** Reconciles a tag inserted by Turbo, a server render, or another controller. */
|
|
351
518
|
tagTargetConnected(_tag) {
|
|
352
519
|
this.#reconcile.schedule();
|
|
353
520
|
}
|
|
354
|
-
/** Reconciles a tag removed by Turbo, a server render, or another controller. */
|
|
355
521
|
tagTargetDisconnected(_tag) {
|
|
356
522
|
this.#reconcile.schedule();
|
|
357
523
|
}
|
|
358
|
-
/** Seeds a fields target inserted after connect. */
|
|
359
524
|
fieldsTargetConnected() {
|
|
360
525
|
this.#reconcile.schedule();
|
|
361
526
|
}
|
|
362
|
-
|
|
527
|
+
fieldsTargetDisconnected() {
|
|
528
|
+
this.#reconcile.schedule();
|
|
529
|
+
}
|
|
363
530
|
nameValueChanged() {
|
|
364
531
|
this.#reconcile.schedule();
|
|
365
532
|
}
|
|
366
|
-
|
|
533
|
+
formValueChanged() {
|
|
534
|
+
this.#reconcile.schedule();
|
|
535
|
+
}
|
|
367
536
|
maxValueChanged() {
|
|
368
537
|
this.#reconcile.schedule();
|
|
369
538
|
}
|
|
370
|
-
/** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
|
|
371
539
|
onKeydown(event) {
|
|
372
540
|
if (event.defaultPrevented) return;
|
|
373
541
|
if (isReservedArrowChord(event)) return;
|
|
@@ -393,14 +561,13 @@ var TagsInputController = class extends Controller {
|
|
|
393
561
|
}
|
|
394
562
|
}
|
|
395
563
|
}
|
|
396
|
-
/** Validates and adds the current input value as a tag, then clears the input. */
|
|
397
564
|
#commitInput() {
|
|
398
565
|
const value = this.inputTarget.value.trim();
|
|
399
566
|
if (value === "") {
|
|
400
567
|
this.#reject(value, "empty");
|
|
401
568
|
return;
|
|
402
569
|
}
|
|
403
|
-
if (this
|
|
570
|
+
if (this.#safeMax > 0 && this.tagTargets.length >= this.#safeMax) {
|
|
404
571
|
this.#reject(value, "max");
|
|
405
572
|
return;
|
|
406
573
|
}
|
|
@@ -411,121 +578,95 @@ var TagsInputController = class extends Controller {
|
|
|
411
578
|
if (!this.#appendTag(value)) return;
|
|
412
579
|
this.inputTarget.value = "";
|
|
413
580
|
const tags = this.#values;
|
|
414
|
-
this.#
|
|
415
|
-
this.#tagValues = tags;
|
|
416
|
-
this.#announceTransition(true, value, tags.length);
|
|
417
|
-
this.dispatch("change", { detail: { tags } });
|
|
581
|
+
this.#publishTags(tags, true, value);
|
|
418
582
|
}
|
|
419
|
-
/** Builds one chip from the template and appends it to the tag list. */
|
|
420
583
|
#appendTag(value) {
|
|
421
584
|
if (!this.hasTagsTarget) return false;
|
|
422
|
-
if (!this.hasTagTemplateTarget)
|
|
423
|
-
|
|
424
|
-
|
|
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');
|
|
585
|
+
if (!this.hasTagTemplateTarget) {
|
|
586
|
+
this.#rows.report('a "tagTemplate" target');
|
|
587
|
+
return false;
|
|
435
588
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
589
|
+
const row = this.#rows.instantiate(this.tagTemplateTarget, { label: value, value });
|
|
590
|
+
if (!row) return false;
|
|
591
|
+
row.root.dataset.value = value;
|
|
592
|
+
writeLabel(row.slots.label, value);
|
|
593
|
+
row.button.tabIndex = -1;
|
|
594
|
+
this.tagsTarget.appendChild(row.root);
|
|
441
595
|
return true;
|
|
442
596
|
}
|
|
443
|
-
/**
|
|
444
|
-
* Reports an unusable chip template to the author, once per connection.
|
|
445
|
-
*
|
|
446
|
-
* The commit itself stays a no-op — nothing about the input, the tag set, the
|
|
447
|
-
* hidden fields, the announcement, or the events changes. Without this line
|
|
448
|
-
* the only symptom is a field that accepts no tags at all, and the two causes
|
|
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.
|
|
452
|
-
*/
|
|
453
|
-
#warnTemplate(missing) {
|
|
454
|
-
if (!this.#warnedTemplate) {
|
|
455
|
-
this.#warnedTemplate = true;
|
|
456
|
-
console.warn(
|
|
457
|
-
`Stimeo UI: "${this.identifier}" added no tag because its chip template lacks ${missing}.`
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
return false;
|
|
461
|
-
}
|
|
462
|
-
/** Removes the tag at `index`, then applies the interaction-origin focus policy. */
|
|
463
597
|
#removeAt(index, focus = "neighbor") {
|
|
464
598
|
const tag = this.tagTargets[index];
|
|
465
599
|
if (!tag) return;
|
|
466
600
|
const value = tag.dataset.value ?? "";
|
|
467
601
|
tag.remove();
|
|
468
602
|
const tags = this.#values;
|
|
469
|
-
this.#
|
|
470
|
-
this.#tagValues = tags;
|
|
471
|
-
this.#announceTransition(false, value, tags.length);
|
|
472
|
-
this.dispatch("change", { detail: { tags } });
|
|
603
|
+
if (!this.#publishTags(tags, false, value)) return;
|
|
473
604
|
if (focus === "input") {
|
|
474
605
|
this.#focusInput();
|
|
475
606
|
return;
|
|
476
607
|
}
|
|
477
608
|
if (!this.#chipRow.focusAfterRemoval(index)) this.#focusInput();
|
|
478
609
|
}
|
|
479
|
-
/** Moves focus to the current input when one exists. */
|
|
480
610
|
#focusInput() {
|
|
481
611
|
if (this.hasInputTarget) this.inputTarget.focus();
|
|
482
612
|
}
|
|
483
|
-
/** Rebuilds the hidden form fields, the `full` flag, and the roving Tab stop. */
|
|
484
613
|
#syncState(values) {
|
|
614
|
+
let fieldChanged = false;
|
|
485
615
|
if (this.hasFieldsTarget) {
|
|
486
|
-
this.
|
|
487
|
-
|
|
488
|
-
const input = document.createElement("input");
|
|
489
|
-
input.type = "hidden";
|
|
490
|
-
input.name = this.nameValue;
|
|
491
|
-
input.value = value;
|
|
492
|
-
return input;
|
|
493
|
-
})
|
|
494
|
-
);
|
|
616
|
+
const options = { name: this.nameValue, form: this.formValue };
|
|
617
|
+
fieldChanged = writeFields(this.fieldsTarget, values, options);
|
|
495
618
|
}
|
|
496
|
-
const full = this
|
|
497
|
-
this.element.toggleAttribute(
|
|
619
|
+
const full = this.#safeMax > 0 && values.length >= this.#safeMax;
|
|
620
|
+
this.element.toggleAttribute(`data-${this.identifier}-full`, full);
|
|
498
621
|
this.#chipRow.ensureTabStop();
|
|
622
|
+
return fieldChanged;
|
|
623
|
+
}
|
|
624
|
+
#publishTags(tags, added, value) {
|
|
625
|
+
const changed = !this.#sameTags(this.#tagValues, tags);
|
|
626
|
+
const fieldChanged = this.#syncState(tags);
|
|
627
|
+
this.#tagValues = tags;
|
|
628
|
+
if (!changed) return true;
|
|
629
|
+
const token = this.#moves.record();
|
|
630
|
+
if (fieldChanged) commitField(this.fieldsTarget);
|
|
631
|
+
if (!this.#moves.isLatest(token)) return false;
|
|
632
|
+
this.#announceTransition(added, value, tags.length);
|
|
633
|
+
if (!this.#moves.isLatest(token)) return false;
|
|
634
|
+
this.dispatch("change", { detail: { tags } });
|
|
635
|
+
return this.#moves.isLatest(token);
|
|
499
636
|
}
|
|
500
|
-
/** Repairs derived state after DOM/Turbo changes and reports a changed tag order. */
|
|
501
637
|
#reconcileTags() {
|
|
502
638
|
if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
|
|
503
|
-
else this.#chipRow.disconnect();
|
|
504
639
|
const tags = this.#values;
|
|
505
640
|
this.#syncState(tags);
|
|
506
641
|
const changed = !this.#sameTags(this.#tagValues, tags);
|
|
507
642
|
this.#tagValues = tags;
|
|
508
|
-
if (changed)
|
|
643
|
+
if (changed) {
|
|
644
|
+
this.dispatch("reconcile", { detail: { tags } });
|
|
645
|
+
}
|
|
509
646
|
}
|
|
510
|
-
/** Sends one localized tag transition through the page's shared announcer. */
|
|
511
647
|
#announceTransition(added, value, count) {
|
|
512
648
|
const template = added ? this.announceTextValue : this.announceRemovedTextValue;
|
|
513
649
|
announce(fillTemplate(template, { label: value, value, count }));
|
|
514
650
|
}
|
|
515
|
-
/** Reports rejection with `{ value, reason: "duplicate" | "empty" | "max" }`. */
|
|
516
651
|
#reject(value, reason) {
|
|
517
652
|
this.dispatch("reject", { detail: { value, reason } });
|
|
518
653
|
}
|
|
519
|
-
/** Current tag values in order. */
|
|
520
654
|
get #values() {
|
|
521
655
|
return this.tagTargets.map((tag) => tag.dataset.value ?? "");
|
|
522
656
|
}
|
|
523
|
-
/** Whether two arrays carry the same values in the same submitted order. */
|
|
524
657
|
#sameTags(left, right) {
|
|
525
658
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
526
659
|
}
|
|
660
|
+
get #safeMax() {
|
|
661
|
+
return this.#numbers.read(
|
|
662
|
+
this,
|
|
663
|
+
"max",
|
|
664
|
+
this.maxValue,
|
|
665
|
+
_TagsInputController.values.max.default,
|
|
666
|
+
_TagsInputController.valueConstraints.max
|
|
667
|
+
);
|
|
668
|
+
}
|
|
527
669
|
};
|
|
528
670
|
|
|
529
671
|
export { TagsInputController };
|
|
530
|
-
//# sourceMappingURL=tags_input_controller.js.map
|
|
531
672
|
//# sourceMappingURL=tags_input_controller.js.map
|