stimeo-ui 0.16.0 → 0.18.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 +136 -0
- data/README.md +27 -11
- data/dist/cable/index.js +384 -449
- data/dist/controllers/accordion_controller.js +174 -55
- data/dist/controllers/alert_dialog_controller.js +903 -235
- data/dist/controllers/announcer_controller.js +238 -205
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +414 -145
- data/dist/controllers/avatar_controller.js +185 -107
- data/dist/controllers/breadcrumb_controller.js +0 -130
- data/dist/controllers/bulk_select_controller.js +282 -58
- data/dist/controllers/calendar_controller.js +228 -260
- data/dist/controllers/carousel_controller.js +536 -436
- data/dist/controllers/character_counter_controller.js +493 -207
- data/dist/controllers/checkbox_controller.js +38 -74
- data/dist/controllers/clipboard_controller.js +261 -149
- data/dist/controllers/collapsible_controller.js +304 -120
- data/dist/controllers/color_picker_controller.js +178 -146
- data/dist/controllers/combobox_controller.js +522 -156
- data/dist/controllers/command_palette_controller.js +1156 -395
- data/dist/controllers/conditional_fields_controller.js +300 -171
- data/dist/controllers/confirm_controller.js +980 -262
- data/dist/controllers/context_menu_controller.js +346 -153
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +281 -158
- data/dist/controllers/currency_input_controller.js +575 -225
- data/dist/controllers/data_grid_controller.js +248 -214
- data/dist/controllers/date_range_picker_controller.js +338 -235
- data/dist/controllers/dialog_controller.js +899 -237
- data/dist/controllers/direct_upload_controller.js +97 -167
- data/dist/controllers/dirty_form_controller.js +72 -69
- data/dist/controllers/dismissible_controller.js +196 -24
- data/dist/controllers/drawer_controller.js +974 -355
- data/dist/controllers/dropdown_controller.js +339 -96
- data/dist/controllers/editable_controller.js +315 -88
- data/dist/controllers/empty_state_controller.js +79 -58
- data/dist/controllers/file_dropzone_controller.js +322 -211
- data/dist/controllers/filter_controller.js +77 -42
- data/dist/controllers/flash_controller.js +238 -347
- data/dist/controllers/focus_controller.js +679 -240
- data/dist/controllers/form_field_controller.js +156 -132
- data/dist/controllers/form_validation_controller.js +31 -97
- data/dist/controllers/frame_loading_controller.js +275 -217
- data/dist/controllers/highlight_controller.js +107 -75
- data/dist/controllers/hover_card_controller.js +327 -130
- data/dist/controllers/idle_controller.js +271 -101
- data/dist/controllers/input_mask_controller.js +483 -97
- data/dist/controllers/intersection_controller.js +150 -106
- data/dist/controllers/lazy_frame_controller.js +24 -68
- data/dist/controllers/listbox_controller.js +523 -240
- data/dist/controllers/local_time_controller.js +102 -77
- data/dist/controllers/masonry_controller.js +80 -96
- data/dist/controllers/menu_controller.js +302 -188
- data/dist/controllers/menubar_controller.js +232 -345
- data/dist/controllers/meter_controller.js +417 -87
- data/dist/controllers/multi_select_controller.js +785 -393
- data/dist/controllers/navigation_menu_controller.js +190 -250
- data/dist/controllers/nested_form_controller.js +396 -192
- data/dist/controllers/network_status_controller.js +268 -62
- data/dist/controllers/number_input_controller.js +610 -303
- data/dist/controllers/optimistic_controller.js +45 -87
- data/dist/controllers/otp_controller.js +658 -255
- data/dist/controllers/overflow_indicator_controller.js +122 -113
- data/dist/controllers/overflow_menu_controller.js +470 -295
- data/dist/controllers/pagination_controller.js +332 -123
- data/dist/controllers/password_reveal_controller.js +392 -121
- data/dist/controllers/password_strength_controller.js +111 -158
- data/dist/controllers/persist_controller.js +165 -111
- data/dist/controllers/pointer_drag_controller.js +434 -188
- data/dist/controllers/popover_controller.js +269 -113
- data/dist/controllers/portal_controller.js +215 -76
- data/dist/controllers/preview_guard_controller.js +243 -116
- data/dist/controllers/progress_controller.js +391 -78
- data/dist/controllers/radio_group_controller.js +278 -211
- data/dist/controllers/range_slider_controller.js +220 -113
- data/dist/controllers/rating_controller.js +458 -211
- data/dist/controllers/read_more_controller.js +138 -63
- data/dist/controllers/reading_progress_controller.js +166 -137
- data/dist/controllers/relative_time_controller.js +211 -112
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
- data/dist/controllers/resizable_controller.js +299 -150
- data/dist/controllers/roving_controller.js +49 -88
- data/dist/controllers/scroll_area_controller.js +490 -246
- data/dist/controllers/scroll_restore_controller.js +14 -55
- data/dist/controllers/scroll_visibility_controller.js +432 -149
- data/dist/controllers/scrollspy_controller.js +193 -248
- data/dist/controllers/separator_controller.js +285 -142
- data/dist/controllers/sidebar_controller.js +1069 -404
- data/dist/controllers/skeleton_controller.js +259 -101
- data/dist/controllers/slider_controller.js +187 -86
- data/dist/controllers/smart_sticky_header_controller.js +173 -66
- data/dist/controllers/sortable_controller.js +26 -125
- data/dist/controllers/spinner_controller.js +288 -173
- data/dist/controllers/step_indicator_controller.js +105 -46
- data/dist/controllers/stepper_controller.js +120 -73
- data/dist/controllers/stick_to_bottom_controller.js +275 -140
- data/dist/controllers/sticky_observer_controller.js +220 -49
- data/dist/controllers/submit_once_controller.js +376 -227
- data/dist/controllers/switch_controller.js +201 -84
- data/dist/controllers/tabs_controller.js +41 -30
- data/dist/controllers/tags_input_controller.js +293 -173
- data/dist/controllers/textarea_autosize_controller.js +108 -74
- data/dist/controllers/theme_controller.js +140 -116
- data/dist/controllers/time_picker_controller.js +331 -127
- data/dist/controllers/toast_controller.js +244 -324
- data/dist/controllers/toggle_group_controller.js +252 -191
- data/dist/controllers/toolbar_controller.js +219 -120
- data/dist/controllers/tooltip_controller.js +158 -124
- data/dist/controllers/transition_controller.js +123 -119
- data/dist/controllers/tree_view_controller.js +283 -369
- data/dist/index.js +8573 -10410
- data/dist/positioning/index.js +154 -47
- data/lib/generators/stimeo/install/install_generator.rb +7 -2
- data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
function fillTemplate(template, values) {
|
|
4
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
5
|
+
const replacement = Object.hasOwn(values, name) ? values[name] : void 0;
|
|
6
|
+
return replacement === void 0 ? match : String(replacement);
|
|
7
|
+
});
|
|
8
|
+
}
|
|
4
9
|
|
|
5
|
-
// src/utils/announce.ts
|
|
6
10
|
function announce(message, options = {}) {
|
|
7
11
|
const text = message.trim();
|
|
8
12
|
if (text.length === 0) return;
|
|
@@ -12,131 +16,216 @@ function announce(message, options = {}) {
|
|
|
12
16
|
})
|
|
13
17
|
);
|
|
14
18
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
function setAttributeIfChanged(element, name, value, beforeWrite) {
|
|
21
|
+
if (element.getAttribute(name) === value) return false;
|
|
22
|
+
if (value === null) element.removeAttribute(name);
|
|
23
|
+
else element.setAttribute(name, value);
|
|
24
|
+
return true;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
function sharedRegistry(key, create) {
|
|
28
|
+
const symbol = Symbol.for(key);
|
|
29
|
+
const scope = globalThis;
|
|
30
|
+
const existing = scope[symbol];
|
|
31
|
+
if (existing !== void 0) return existing;
|
|
32
|
+
const registry2 = create();
|
|
33
|
+
Object.defineProperty(globalThis, symbol, { value: registry2 });
|
|
34
|
+
return registry2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
var registry = sharedRegistry(
|
|
38
|
+
"stimeo-ui.attribute-lease.registry.v1",
|
|
39
|
+
() => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
|
|
40
|
+
);
|
|
41
|
+
function isAttributeValue(value) {
|
|
42
|
+
return value === null || typeof value === "string";
|
|
43
|
+
}
|
|
44
|
+
function readLeaseRecord(element, record) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(element.getAttribute(record) ?? "null");
|
|
47
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
48
|
+
const [original, written, ...rest] = parsed;
|
|
49
|
+
const linked = rest[0] !== false;
|
|
50
|
+
const beneath = linked ? rest : rest.slice(1);
|
|
51
|
+
if (!beneath.every((under) => typeof under === "string")) return void 0;
|
|
52
|
+
return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
|
|
53
|
+
} catch {
|
|
54
|
+
return void 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function attributeStack(element, attribute) {
|
|
58
|
+
const byAttribute = registry.stacks.get(element) ?? /* @__PURE__ */ new Map();
|
|
59
|
+
registry.stacks.set(element, byAttribute);
|
|
60
|
+
const known = byAttribute.get(attribute);
|
|
61
|
+
if (known) return known;
|
|
62
|
+
const found = [];
|
|
63
|
+
for (const record of registry.names.get(attribute) ?? []) {
|
|
64
|
+
const read = readLeaseRecord(element, record);
|
|
65
|
+
if (read) found.push({ ...read, record });
|
|
66
|
+
}
|
|
67
|
+
const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
|
|
68
|
+
const stack = found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
|
|
69
|
+
lease: null,
|
|
70
|
+
record,
|
|
71
|
+
written,
|
|
72
|
+
original,
|
|
73
|
+
linked: linked && index > 0
|
|
74
|
+
}));
|
|
75
|
+
byAttribute.set(attribute, stack);
|
|
76
|
+
return stack;
|
|
77
|
+
}
|
|
78
|
+
function findHold(stack, lease, record) {
|
|
79
|
+
return stack.find((held) => held.lease === lease) ?? stack.find((held) => held.lease === null && held.record === record);
|
|
80
|
+
}
|
|
81
|
+
var AttributeLease = class {
|
|
82
|
+
#attribute;
|
|
83
|
+
#record;
|
|
84
|
+
#base;
|
|
85
|
+
#preserveAuthor;
|
|
86
|
+
#held = /* @__PURE__ */ new Set();
|
|
87
|
+
constructor(attribute, owner, options = {}) {
|
|
88
|
+
this.#attribute = attribute;
|
|
89
|
+
this.#record = `data-${owner}-${attribute}-lease`;
|
|
90
|
+
this.#base = options.base === true;
|
|
91
|
+
this.#preserveAuthor = options.preserveAuthor === true;
|
|
92
|
+
const names = registry.names;
|
|
93
|
+
names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
|
|
94
|
+
}
|
|
95
|
+
write(element, value) {
|
|
96
|
+
const stack = attributeStack(element, this.#attribute);
|
|
97
|
+
const hold = this.#take(stack) ?? this.#acquire(element, stack);
|
|
98
|
+
this.#held.add(element);
|
|
99
|
+
const shows = !this.#covered(element, stack, hold);
|
|
100
|
+
hold.written = value;
|
|
101
|
+
this.#mark(element, stack);
|
|
102
|
+
if (shows) this.#reflect(element, value);
|
|
103
|
+
}
|
|
104
|
+
return(element) {
|
|
105
|
+
const held = this.#held.delete(element);
|
|
106
|
+
const known = registry.stacks.get(element)?.has(this.#attribute);
|
|
107
|
+
const stack = held || known || element.hasAttribute(this.#record) ? attributeStack(element, this.#attribute) : [];
|
|
108
|
+
const hold = this.#take(stack);
|
|
109
|
+
if (!hold) return false;
|
|
110
|
+
const carries = element.getAttribute(this.#attribute) === hold.written;
|
|
111
|
+
const owns = carries && !this.#covered(element, stack, hold);
|
|
112
|
+
const index = stack.indexOf(hold);
|
|
113
|
+
stack.splice(index, 1);
|
|
114
|
+
const above = stack[index];
|
|
115
|
+
if (above) above.linked &&= hold.linked;
|
|
116
|
+
this.#mark(element, stack);
|
|
117
|
+
if (!owns) return true;
|
|
118
|
+
const below = hold.linked ? stack[index - 1] : void 0;
|
|
119
|
+
this.#reflect(element, below ? below.written : hold.original);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
returnAll() {
|
|
123
|
+
for (const element of Array.from(this.#held)) this.return(element);
|
|
124
|
+
}
|
|
125
|
+
#take(stack) {
|
|
126
|
+
const hold = findHold(stack, this, this.#record);
|
|
127
|
+
if (hold) hold.lease = this;
|
|
128
|
+
return hold;
|
|
129
|
+
}
|
|
130
|
+
#acquire(element, stack) {
|
|
131
|
+
const current = element.getAttribute(this.#attribute);
|
|
132
|
+
const top = stack.at(-1);
|
|
133
|
+
const bottom = stack[0];
|
|
134
|
+
const over = top?.written === current ? top : void 0;
|
|
135
|
+
const from = this.#base ? this.#preserveAuthor && !stack.some((held) => held.written === current) ? void 0 : bottom : over;
|
|
136
|
+
const hold = {
|
|
137
|
+
lease: this,
|
|
138
|
+
record: this.#record,
|
|
139
|
+
written: current,
|
|
140
|
+
original: from ? from.original : current,
|
|
141
|
+
linked: !this.#base && over !== void 0
|
|
142
|
+
};
|
|
143
|
+
if (this.#base && bottom) {
|
|
144
|
+
bottom.linked = true;
|
|
145
|
+
stack.unshift(hold);
|
|
146
|
+
} else stack.push(hold);
|
|
147
|
+
return hold;
|
|
148
|
+
}
|
|
149
|
+
#covered(element, stack, hold) {
|
|
150
|
+
const current = element.getAttribute(this.#attribute);
|
|
151
|
+
return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
|
|
152
|
+
}
|
|
153
|
+
#mark(element, stack) {
|
|
154
|
+
const records = /* @__PURE__ */ new Map([[this.#record, null]]);
|
|
155
|
+
stack.forEach((hold, index) => {
|
|
156
|
+
const names = stack.slice(0, index).map((under) => under.record);
|
|
157
|
+
const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
|
|
158
|
+
const marker = hold.linked || beneath.length === 0 ? [] : [false];
|
|
159
|
+
records.set(
|
|
160
|
+
hold.record,
|
|
161
|
+
hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
for (const [name, recorded] of records) {
|
|
165
|
+
setAttributeIfChanged(element, name, recorded);
|
|
41
166
|
}
|
|
42
167
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
_BeforeCacheReset.#subscribers.delete(this);
|
|
46
|
-
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
47
|
-
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
168
|
+
#reflect(element, value) {
|
|
169
|
+
setAttributeIfChanged(element, this.#attribute, value);
|
|
48
170
|
}
|
|
49
171
|
};
|
|
50
172
|
|
|
51
|
-
// src/utils/default_attribute.ts
|
|
52
173
|
function setDefaultAttribute(element, name, value) {
|
|
53
174
|
if (element.hasAttribute(name)) return false;
|
|
54
175
|
element.setAttribute(name, value);
|
|
55
176
|
return true;
|
|
56
177
|
}
|
|
57
178
|
|
|
58
|
-
// src/utils/detach_gate.ts
|
|
59
179
|
var DetachGate = class _DetachGate {
|
|
60
|
-
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
61
180
|
#pending = false;
|
|
62
|
-
/**
|
|
63
|
-
* True while a probe is queued — the last disconnect was ambiguous and no
|
|
64
|
-
* reconnect has cancelled it yet. Read it from `connect()` to tell the
|
|
65
|
-
* reconnect half of an in-page move from a first connect: a controller whose
|
|
66
|
-
* initialisation restarts a measurement (a min-duration floor, an elapsed
|
|
67
|
-
* counter) must skip it for the move, where nothing actually restarted.
|
|
68
|
-
*/
|
|
69
181
|
get pending() {
|
|
70
182
|
return this.#pending;
|
|
71
183
|
}
|
|
72
|
-
/**
|
|
73
|
-
* True when the disconnect is definitely a real detach — the element left
|
|
74
|
-
* the document, or `data-controller` no longer lists the identifier. False
|
|
75
|
-
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
76
|
-
*/
|
|
77
184
|
static isDetached(host) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return !tokens.includes(host.identifier);
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
84
|
-
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
85
|
-
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
86
|
-
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
87
|
-
* element within the same mutation batch, before the checkpoint drains.
|
|
88
|
-
*/
|
|
185
|
+
return !host.element.isConnected || !_DetachGate.#listed(host);
|
|
186
|
+
}
|
|
89
187
|
disconnected(host, teardown) {
|
|
90
|
-
|
|
188
|
+
const carried = _DetachGate.#carried(host);
|
|
189
|
+
if (!carried && _DetachGate.isDetached(host)) {
|
|
91
190
|
this.#pending = false;
|
|
92
191
|
teardown();
|
|
93
192
|
return;
|
|
94
193
|
}
|
|
95
194
|
this.#pending = true;
|
|
96
|
-
|
|
195
|
+
const probe = () => {
|
|
97
196
|
if (!this.#pending) return;
|
|
98
197
|
this.#pending = false;
|
|
99
198
|
teardown();
|
|
100
|
-
}
|
|
199
|
+
};
|
|
200
|
+
if (carried) setTimeout(probe, 0);
|
|
201
|
+
else queueMicrotask(probe);
|
|
202
|
+
}
|
|
203
|
+
static #carried(host) {
|
|
204
|
+
const element = host.element;
|
|
205
|
+
return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
|
|
206
|
+
}
|
|
207
|
+
static #listed(host) {
|
|
208
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
209
|
+
return tokens.includes(host.identifier);
|
|
101
210
|
}
|
|
102
|
-
/**
|
|
103
|
-
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
104
|
-
* an in-page move) and from the head of any teardown path not routed through
|
|
105
|
-
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
106
|
-
* never run the teardown a second time.
|
|
107
|
-
*/
|
|
108
211
|
cancel() {
|
|
109
212
|
this.#pending = false;
|
|
110
213
|
}
|
|
111
214
|
};
|
|
112
215
|
|
|
113
|
-
// src/utils/min_duration_floor.ts
|
|
114
216
|
var MinDurationFloor = class {
|
|
115
217
|
#timers;
|
|
116
|
-
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
117
218
|
#timerId = null;
|
|
118
|
-
/** Epoch ms the floor is measured from. */
|
|
119
219
|
#since = 0;
|
|
120
|
-
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
121
220
|
constructor(timers) {
|
|
122
221
|
this.#timers = timers;
|
|
123
222
|
}
|
|
124
|
-
/** Starts the floor: call when the state being held becomes visible. */
|
|
125
223
|
begin() {
|
|
126
224
|
this.#since = Date.now();
|
|
127
225
|
}
|
|
128
|
-
/** True while a finish is held back waiting for the floor to elapse. */
|
|
129
226
|
get pending() {
|
|
130
227
|
return this.#timerId !== null;
|
|
131
228
|
}
|
|
132
|
-
/**
|
|
133
|
-
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
134
|
-
*
|
|
135
|
-
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
136
|
-
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
137
|
-
* end a state that has since restarted. Controllers that want the first signal
|
|
138
|
-
* to win guard on {@link pending} before calling.
|
|
139
|
-
*/
|
|
140
229
|
schedule(minDuration, finish) {
|
|
141
230
|
this.cancel();
|
|
142
231
|
const remaining = minDuration - (Date.now() - this.#since);
|
|
@@ -149,7 +238,6 @@ var MinDurationFloor = class {
|
|
|
149
238
|
finish();
|
|
150
239
|
}
|
|
151
240
|
}
|
|
152
|
-
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
153
241
|
cancel() {
|
|
154
242
|
if (this.#timerId !== null) {
|
|
155
243
|
this.#timers.clear(this.#timerId);
|
|
@@ -158,43 +246,70 @@ var MinDurationFloor = class {
|
|
|
158
246
|
}
|
|
159
247
|
};
|
|
160
248
|
|
|
161
|
-
|
|
249
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
250
|
+
|
|
251
|
+
var NUMBER_BOUNDS = {
|
|
252
|
+
timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
|
|
253
|
+
function matchesNumberBounds(value, bounds) {
|
|
254
|
+
if (!Number.isFinite(value)) {
|
|
255
|
+
const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
|
|
256
|
+
if (direction === null) return false;
|
|
257
|
+
if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
|
|
258
|
+
}
|
|
259
|
+
if (bounds.min !== void 0 && value < bounds.min) return false;
|
|
260
|
+
if (bounds.max !== void 0 && value > bounds.max) return false;
|
|
261
|
+
if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
|
|
262
|
+
if (bounds.integer && !Number.isInteger(value)) return false;
|
|
263
|
+
if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readNumber(raw, fallback, bounds) {
|
|
268
|
+
return matchesNumberBounds(raw, bounds) ? raw : fallback;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
var NumberValueReader = class {
|
|
272
|
+
#lastRejected = /* @__PURE__ */ new Map();
|
|
273
|
+
read(owner, name, raw, fallback, bounds) {
|
|
274
|
+
const resolved = readNumber(raw, fallback, bounds);
|
|
275
|
+
if (matchesNumberBounds(raw, bounds)) {
|
|
276
|
+
this.#lastRejected.delete(name);
|
|
277
|
+
return resolved;
|
|
278
|
+
}
|
|
279
|
+
const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
|
|
280
|
+
const literal = owner.element.getAttribute(attribute);
|
|
281
|
+
if (literal === null) {
|
|
282
|
+
this.#lastRejected.delete(name);
|
|
283
|
+
return resolved;
|
|
284
|
+
}
|
|
285
|
+
if (this.#lastRejected.get(name) !== literal) {
|
|
286
|
+
this.#lastRejected.set(name, literal);
|
|
287
|
+
console.warn(
|
|
288
|
+
`Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return resolved;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
162
295
|
var TimerRegistry = class {
|
|
163
|
-
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
164
296
|
ids = /* @__PURE__ */ new Set();
|
|
165
|
-
/**
|
|
166
|
-
* Cancels a single tracked timer.
|
|
167
|
-
*
|
|
168
|
-
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
169
|
-
* registry), so callers can clear defensively without guarding.
|
|
170
|
-
*/
|
|
171
297
|
clear(id) {
|
|
172
298
|
if (this.ids.delete(id)) {
|
|
173
299
|
this.cancel(id);
|
|
174
300
|
}
|
|
175
301
|
}
|
|
176
|
-
/**
|
|
177
|
-
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
178
|
-
* to guarantee no timer outlives the element.
|
|
179
|
-
*/
|
|
180
302
|
clearAll() {
|
|
181
303
|
for (const id of this.ids) {
|
|
182
304
|
this.cancel(id);
|
|
183
305
|
}
|
|
184
306
|
this.ids.clear();
|
|
185
307
|
}
|
|
186
|
-
/** Number of timers currently tracked (pending). */
|
|
187
308
|
get size() {
|
|
188
309
|
return this.ids.size;
|
|
189
310
|
}
|
|
190
311
|
};
|
|
191
312
|
var SafeTimeout = class extends TimerRegistry {
|
|
192
|
-
/**
|
|
193
|
-
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
194
|
-
*
|
|
195
|
-
* The id is removed from the registry automatically when the timeout fires,
|
|
196
|
-
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
197
|
-
*/
|
|
198
313
|
set(callback, delay) {
|
|
199
314
|
const id = this.schedule(() => {
|
|
200
315
|
this.ids.delete(id);
|
|
@@ -211,8 +326,8 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
211
326
|
}
|
|
212
327
|
};
|
|
213
328
|
|
|
214
|
-
|
|
215
|
-
|
|
329
|
+
var SpinnerController = class _SpinnerController extends Controller {
|
|
330
|
+
#numbers = new NumberValueReader();
|
|
216
331
|
static targets = ["indicator", "region", "message"];
|
|
217
332
|
static values = {
|
|
218
333
|
announceText: { type: String, default: "" },
|
|
@@ -221,42 +336,52 @@ var SpinnerController = class extends Controller {
|
|
|
221
336
|
minDuration: { type: Number, default: 0 },
|
|
222
337
|
timeout: { type: Number, default: 0 }
|
|
223
338
|
};
|
|
339
|
+
static valueConstraints = {
|
|
340
|
+
delay: NUMBER_BOUNDS.timer,
|
|
341
|
+
minDuration: NUMBER_BOUNDS.timer,
|
|
342
|
+
timeout: NUMBER_BOUNDS.timer
|
|
343
|
+
};
|
|
224
344
|
static actions = ["start", "stop"];
|
|
225
345
|
static events = ["hide", "show", "timeout", "reconcile"];
|
|
226
346
|
#timers = new SafeTimeout();
|
|
227
347
|
#floor = new MinDurationFloor(this.#timers);
|
|
228
348
|
#gate = new DetachGate();
|
|
229
|
-
#
|
|
230
|
-
|
|
349
|
+
#busyLease = new AttributeLease("aria-busy", this.identifier);
|
|
350
|
+
#hiddenLease = new AttributeLease("hidden", this.identifier);
|
|
351
|
+
#connected = false;
|
|
352
|
+
#cycling = false;
|
|
231
353
|
#delayTimerId = null;
|
|
232
|
-
/** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
|
|
233
354
|
#timeoutTimerId = null;
|
|
234
355
|
connect() {
|
|
356
|
+
this.#connected = true;
|
|
235
357
|
this.#gate.cancel();
|
|
236
|
-
this.#
|
|
237
|
-
if (this.#state === "pending" && this.#delayTimerId === null) {
|
|
358
|
+
if (this.#state !== "idle" && !this.#cycling) {
|
|
238
359
|
this.#setBusy(false);
|
|
360
|
+
this.#showIndicator(false);
|
|
239
361
|
this.element.setAttribute("data-state", "idle");
|
|
362
|
+
this.dispatch("reconcile", { detail: {} });
|
|
240
363
|
return;
|
|
241
364
|
}
|
|
242
365
|
setDefaultAttribute(this.element, "data-state", "idle");
|
|
243
366
|
}
|
|
244
|
-
/**
|
|
245
|
-
* Re-applies the current phase to an indicator that arrived after `connect()`.
|
|
246
|
-
*
|
|
247
|
-
* A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
|
|
248
|
-
* carries the markup contract's `hidden`. Without this the spinner would vanish
|
|
249
|
-
* while `data-state` still says `loading`, and nothing but the next cycle would
|
|
250
|
-
* bring it back.
|
|
251
|
-
*/
|
|
252
367
|
indicatorTargetConnected(target) {
|
|
253
|
-
target
|
|
368
|
+
this.#hiddenLease.write(target, this.#state === "loading" ? null : "");
|
|
369
|
+
}
|
|
370
|
+
indicatorTargetDisconnected(indicator) {
|
|
371
|
+
if (!this.indicatorTargets.includes(indicator)) this.#hiddenLease.return(indicator);
|
|
372
|
+
if (this.#connected) this.#showIndicator(this.#state === "loading");
|
|
373
|
+
}
|
|
374
|
+
regionTargetConnected(region) {
|
|
375
|
+
if (this.#connected) this.#busyLease.write(region, String(this.#busy));
|
|
376
|
+
}
|
|
377
|
+
regionTargetDisconnected(region) {
|
|
378
|
+
if (!this.regionTargets.includes(region)) this.#busyLease.return(region);
|
|
379
|
+
if (this.#connected) this.#setBusy(this.#busy);
|
|
254
380
|
}
|
|
255
381
|
disconnect() {
|
|
256
|
-
this.#
|
|
382
|
+
this.#connected = false;
|
|
257
383
|
this.#gate.disconnected(this, () => this.#teardown());
|
|
258
384
|
}
|
|
259
|
-
/** Begins loading. Honors `delay` before the spinner actually appears. */
|
|
260
385
|
start() {
|
|
261
386
|
if (this.#state === "loading") {
|
|
262
387
|
this.#setBusy(true);
|
|
@@ -265,107 +390,68 @@ var SpinnerController = class extends Controller {
|
|
|
265
390
|
return;
|
|
266
391
|
}
|
|
267
392
|
if (this.#state !== "idle") return;
|
|
393
|
+
this.#cycling = true;
|
|
268
394
|
this.#setBusy(true);
|
|
269
395
|
this.#floor.cancel();
|
|
270
396
|
this.#armTimeout();
|
|
271
|
-
if (this
|
|
397
|
+
if (this.#safeDelay > 0) {
|
|
272
398
|
this.element.setAttribute("data-state", "pending");
|
|
273
399
|
this.#delayTimerId = this.#timers.set(() => {
|
|
274
400
|
this.#delayTimerId = null;
|
|
275
401
|
this.#show();
|
|
276
|
-
}, this
|
|
402
|
+
}, this.#safeDelay);
|
|
277
403
|
} else {
|
|
278
404
|
this.#show();
|
|
279
405
|
}
|
|
280
406
|
}
|
|
281
|
-
/** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
|
|
282
407
|
stop() {
|
|
283
408
|
const state = this.#state;
|
|
284
409
|
this.#cancelTimeout();
|
|
285
410
|
if (state === "pending") {
|
|
286
411
|
this.#cancelDelay();
|
|
412
|
+
this.#cycling = false;
|
|
287
413
|
this.#setBusy(false);
|
|
288
414
|
this.element.setAttribute("data-state", "idle");
|
|
289
415
|
return;
|
|
290
416
|
}
|
|
291
417
|
if (state !== "loading") return;
|
|
292
418
|
this.#setBusy(false);
|
|
293
|
-
this.#floor.schedule(this
|
|
419
|
+
this.#floor.schedule(this.#safeMinDuration, () => this.#hide());
|
|
294
420
|
}
|
|
295
|
-
/**
|
|
296
|
-
* Reveals the indicator, marks the moment shown, and announces via the live region.
|
|
297
|
-
*
|
|
298
|
-
* @stimeoRuntimeOnly `announceText` is the wording of the one announcement this show makes.
|
|
299
|
-
*/
|
|
300
421
|
#show() {
|
|
301
422
|
this.#floor.begin();
|
|
302
|
-
this.#
|
|
303
|
-
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
|
|
423
|
+
this.#showIndicator(true);
|
|
304
424
|
this.element.setAttribute("data-state", "loading");
|
|
305
425
|
this.dispatch("show", { detail: {} });
|
|
306
426
|
announce(fillTemplate(this.announceTextValue, {}));
|
|
307
427
|
}
|
|
308
|
-
/**
|
|
309
|
-
* Hides the indicator and returns to the idle state.
|
|
310
|
-
*
|
|
311
|
-
* @stimeoRuntimeOnly `announceReadyText` is the wording of the one announcement this hide makes.
|
|
312
|
-
*/
|
|
313
428
|
#hide() {
|
|
314
|
-
|
|
429
|
+
this.#cycling = false;
|
|
430
|
+
this.#showIndicator(false);
|
|
315
431
|
this.element.setAttribute("data-state", "idle");
|
|
316
432
|
this.dispatch("hide", { detail: {} });
|
|
317
433
|
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
318
434
|
}
|
|
319
|
-
/**
|
|
320
|
-
* Drops both timers on a real detach. The markup keeps whatever it last held: an
|
|
321
|
-
* element on its way out of the document has no reader left, and one whose
|
|
322
|
-
* `data-controller` dropped the identifier no longer resolves its own targets, so
|
|
323
|
-
* the rollback could only ever be partial. The snapshot is rewound where it is
|
|
324
|
-
* still whole, on `turbo:before-cache`.
|
|
325
|
-
*/
|
|
326
435
|
#teardown() {
|
|
327
|
-
this.#gate.cancel();
|
|
328
436
|
this.#timers.clearAll();
|
|
329
437
|
this.#delayTimerId = null;
|
|
330
438
|
this.#timeoutTimerId = null;
|
|
331
|
-
this.#
|
|
439
|
+
this.#cycling = false;
|
|
332
440
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
* so a page reached with the Back button is not restored mid-load with a
|
|
336
|
-
* spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
|
|
337
|
-
* and `aria-busy`. No `hide` is dispatched — that would claim the load finished.
|
|
338
|
-
* The rewind reports itself as `reconcile` instead, so a consumer painting from
|
|
339
|
-
* `show` can drop it before the snapshot is taken. The live page keeps its
|
|
340
|
-
* timers, so a navigation that never completes leaves the running cycle intact.
|
|
341
|
-
*/
|
|
342
|
-
#rewindForCache() {
|
|
343
|
-
const running = this.element.getAttribute("data-state") !== "idle";
|
|
344
|
-
this.#cancelTimeout();
|
|
345
|
-
this.#setBusy(false);
|
|
346
|
-
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
|
|
347
|
-
this.element.setAttribute("data-state", "idle");
|
|
348
|
-
if (running) this.dispatch("reconcile", { detail: {} });
|
|
441
|
+
#showIndicator(shown) {
|
|
442
|
+
if (this.hasIndicatorTarget) this.#hiddenLease.write(this.indicatorTarget, shown ? null : "");
|
|
349
443
|
}
|
|
350
|
-
/** Reflects busy state onto the controlled region (if present). */
|
|
351
444
|
#setBusy(busy) {
|
|
352
|
-
if (this.hasRegionTarget)
|
|
353
|
-
this.regionTarget.setAttribute("aria-busy", String(busy));
|
|
354
|
-
}
|
|
445
|
+
if (this.hasRegionTarget) this.#busyLease.write(this.regionTarget, String(busy));
|
|
355
446
|
}
|
|
356
|
-
/**
|
|
357
|
-
* Arms the safety net so a `stop` that never arrives cannot strand the spinner.
|
|
358
|
-
* Off by default: the consumer owns the async work, so only it knows whether a
|
|
359
|
-
* ceiling makes sense. Re-arming on a restart measures from the newest start.
|
|
360
|
-
*/
|
|
361
447
|
#armTimeout() {
|
|
362
448
|
this.#cancelTimeout();
|
|
363
|
-
if (this
|
|
449
|
+
if (this.#safeTimeout <= 0) return;
|
|
364
450
|
this.#timeoutTimerId = this.#timers.set(() => {
|
|
365
451
|
this.#timeoutTimerId = null;
|
|
366
452
|
this.dispatch("timeout", { detail: {} });
|
|
367
453
|
this.stop();
|
|
368
|
-
}, this
|
|
454
|
+
}, this.#safeTimeout);
|
|
369
455
|
}
|
|
370
456
|
#cancelTimeout() {
|
|
371
457
|
if (this.#timeoutTimerId !== null) {
|
|
@@ -379,12 +465,41 @@ var SpinnerController = class extends Controller {
|
|
|
379
465
|
this.#delayTimerId = null;
|
|
380
466
|
}
|
|
381
467
|
}
|
|
382
|
-
/** Current lifecycle phase as reflected on `data-state`. */
|
|
383
468
|
get #state() {
|
|
384
469
|
return this.element.getAttribute("data-state") ?? "idle";
|
|
385
470
|
}
|
|
471
|
+
get #busy() {
|
|
472
|
+
const state = this.#state;
|
|
473
|
+
return state === "pending" || state === "loading" && !this.#floor.pending;
|
|
474
|
+
}
|
|
475
|
+
get #safeDelay() {
|
|
476
|
+
return this.#numbers.read(
|
|
477
|
+
this,
|
|
478
|
+
"delay",
|
|
479
|
+
this.delayValue,
|
|
480
|
+
_SpinnerController.values.delay.default,
|
|
481
|
+
_SpinnerController.valueConstraints.delay
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
get #safeMinDuration() {
|
|
485
|
+
return this.#numbers.read(
|
|
486
|
+
this,
|
|
487
|
+
"minDuration",
|
|
488
|
+
this.minDurationValue,
|
|
489
|
+
_SpinnerController.values.minDuration.default,
|
|
490
|
+
_SpinnerController.valueConstraints.minDuration
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
get #safeTimeout() {
|
|
494
|
+
return this.#numbers.read(
|
|
495
|
+
this,
|
|
496
|
+
"timeout",
|
|
497
|
+
this.timeoutValue,
|
|
498
|
+
_SpinnerController.values.timeout.default,
|
|
499
|
+
_SpinnerController.valueConstraints.timeout
|
|
500
|
+
);
|
|
501
|
+
}
|
|
386
502
|
};
|
|
387
503
|
|
|
388
504
|
export { SpinnerController };
|
|
389
|
-
//# sourceMappingURL=spinner_controller.js.map
|
|
390
505
|
//# sourceMappingURL=spinner_controller.js.map
|