stimeo-ui 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +184 -0
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +195 -40
- data/dist/controllers/breadcrumb_controller.js +5 -1
- data/dist/controllers/carousel_controller.js +90 -10
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/clipboard_controller.js +8 -3
- data/dist/controllers/collapsible_controller.js +4 -1
- data/dist/controllers/color_picker_controller.js +41 -11
- data/dist/controllers/context_menu_controller.js +2 -2
- data/dist/controllers/countdown_controller.js +5 -1
- data/dist/controllers/date_range_picker_controller.js +162 -31
- data/dist/controllers/direct_upload_controller.js +3 -3
- data/dist/controllers/empty_state_controller.js +107 -16
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/flash_controller.js +161 -21
- data/dist/controllers/form_validation_controller.js +8 -2
- data/dist/controllers/frame_loading_controller.js +94 -22
- data/dist/controllers/highlight_controller.js +38 -1
- data/dist/controllers/idle_controller.js +39 -6
- data/dist/controllers/local_time_controller.js +2 -0
- data/dist/controllers/masonry_controller.js +1 -1
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/meter_controller.js +3 -1
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/network_status_controller.js +1 -3
- data/dist/controllers/number_input_controller.js +455 -64
- data/dist/controllers/overflow_menu_controller.js +5 -1
- data/dist/controllers/pagination_controller.js +38 -1
- data/dist/controllers/password_strength_controller.js +21 -3
- data/dist/controllers/persist_controller.js +24 -5
- data/dist/controllers/pointer_drag_controller.js +10 -0
- data/dist/controllers/portal_controller.js +10 -0
- data/dist/controllers/progress_controller.js +7 -3
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/range_slider_controller.js +385 -94
- data/dist/controllers/rating_controller.js +274 -89
- data/dist/controllers/relative_time_controller.js +2 -0
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +155 -23
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/separator_controller.js +13 -17
- data/dist/controllers/skeleton_controller.js +71 -3
- data/dist/controllers/slider_controller.js +325 -48
- data/dist/controllers/spinner_controller.js +18 -3
- data/dist/controllers/step_indicator_controller.js +3 -1
- data/dist/controllers/stepper_controller.js +2 -0
- data/dist/controllers/switch_controller.js +162 -18
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/textarea_autosize_controller.js +1 -1
- data/dist/controllers/time_picker_controller.js +296 -104
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +24 -4
- data/dist/index.js +3811 -1048
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -124,9 +124,7 @@ var NetworkStatusController = class extends Controller {
|
|
|
124
124
|
#showOffline() {
|
|
125
125
|
this.#timers.clearAll();
|
|
126
126
|
if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
|
|
127
|
-
if (this.hasOfflineTarget)
|
|
128
|
-
this.offlineTarget.hidden = false;
|
|
129
|
-
}
|
|
127
|
+
if (this.hasOfflineTarget) this.offlineTarget.hidden = false;
|
|
130
128
|
}
|
|
131
129
|
/** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
|
|
132
130
|
#showOnline() {
|
|
@@ -8,6 +8,123 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
8
8
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/utils/attribute_lease.ts
|
|
12
|
+
var AttributeLease = class {
|
|
13
|
+
#attribute;
|
|
14
|
+
#records = /* @__PURE__ */ new Map();
|
|
15
|
+
/** @param attribute - The attribute whose temporary values this lease owns. */
|
|
16
|
+
constructor(attribute) {
|
|
17
|
+
this.#attribute = attribute;
|
|
18
|
+
}
|
|
19
|
+
/** Writes or removes the leased attribute while preserving its authored value. */
|
|
20
|
+
write(element, value) {
|
|
21
|
+
const existing = this.#records.get(element);
|
|
22
|
+
if (existing) {
|
|
23
|
+
existing.written = value;
|
|
24
|
+
} else {
|
|
25
|
+
this.#records.set(element, {
|
|
26
|
+
original: element.getAttribute(this.#attribute),
|
|
27
|
+
written: value
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
31
|
+
else element.setAttribute(this.#attribute, value);
|
|
32
|
+
}
|
|
33
|
+
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
34
|
+
return(element) {
|
|
35
|
+
const record = this.#records.get(element);
|
|
36
|
+
if (!record) return;
|
|
37
|
+
this.#records.delete(element);
|
|
38
|
+
if (element.getAttribute(this.#attribute) !== record.written) return;
|
|
39
|
+
if (record.original === null) element.removeAttribute(this.#attribute);
|
|
40
|
+
else element.setAttribute(this.#attribute, record.original);
|
|
41
|
+
}
|
|
42
|
+
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
43
|
+
returnAll() {
|
|
44
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/utils/composition_tracker.ts
|
|
49
|
+
var CompositionTracker = class {
|
|
50
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
51
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
52
|
+
#onStart;
|
|
53
|
+
#onEnd;
|
|
54
|
+
constructor(options = {}) {
|
|
55
|
+
this.#onStart = options.onStart;
|
|
56
|
+
this.#onEnd = options.onEnd;
|
|
57
|
+
}
|
|
58
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
59
|
+
observe(target) {
|
|
60
|
+
if (this.#observedTargets.has(target)) return;
|
|
61
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
62
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
63
|
+
this.#observedTargets.add(target);
|
|
64
|
+
}
|
|
65
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
66
|
+
unobserve(target) {
|
|
67
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
68
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
69
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
70
|
+
this.#activeTargets.delete(target);
|
|
71
|
+
}
|
|
72
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
73
|
+
disconnect() {
|
|
74
|
+
for (const target of this.#observedTargets) {
|
|
75
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
76
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
77
|
+
}
|
|
78
|
+
this.#observedTargets.clear();
|
|
79
|
+
this.#activeTargets.clear();
|
|
80
|
+
}
|
|
81
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
82
|
+
isComposing(event) {
|
|
83
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
84
|
+
}
|
|
85
|
+
#handleStart = (event) => {
|
|
86
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
87
|
+
this.#onStart?.(event);
|
|
88
|
+
};
|
|
89
|
+
#handleEnd = (event) => {
|
|
90
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
91
|
+
this.#onEnd?.(event);
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// src/utils/microtask_coalescer.ts
|
|
96
|
+
var MicrotaskCoalescer = class {
|
|
97
|
+
#run;
|
|
98
|
+
#queued = false;
|
|
99
|
+
#active = false;
|
|
100
|
+
#generation = 0;
|
|
101
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
102
|
+
constructor(run) {
|
|
103
|
+
this.#run = run;
|
|
104
|
+
}
|
|
105
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
106
|
+
activate() {
|
|
107
|
+
this.#active = true;
|
|
108
|
+
}
|
|
109
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
110
|
+
cancel() {
|
|
111
|
+
this.#active = false;
|
|
112
|
+
this.#queued = false;
|
|
113
|
+
this.#generation += 1;
|
|
114
|
+
}
|
|
115
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
116
|
+
schedule() {
|
|
117
|
+
if (!this.#active || this.#queued) return;
|
|
118
|
+
this.#queued = true;
|
|
119
|
+
const generation = this.#generation;
|
|
120
|
+
queueMicrotask(() => {
|
|
121
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
122
|
+
this.#queued = false;
|
|
123
|
+
this.#run();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
11
128
|
// src/utils/safe_timeout.ts
|
|
12
129
|
var TimerRegistry = class {
|
|
13
130
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -75,7 +192,114 @@ var SafeInterval = class extends TimerRegistry {
|
|
|
75
192
|
}
|
|
76
193
|
};
|
|
77
194
|
|
|
195
|
+
// src/utils/stepped_value.ts
|
|
196
|
+
function effectiveStep(step) {
|
|
197
|
+
return Number.isFinite(step) && step > 0 ? step : 1;
|
|
198
|
+
}
|
|
199
|
+
function snapSteppedValue(raw, range) {
|
|
200
|
+
if (!(range.min <= range.max)) return finiteFallback(range.min, range.max);
|
|
201
|
+
const input = Number.isNaN(raw) ? finiteFallback(range.min, range.max) : raw;
|
|
202
|
+
const clamped = Math.min(range.max, Math.max(range.min, input));
|
|
203
|
+
const step = effectiveStep(range.step);
|
|
204
|
+
const base = stepBase(range);
|
|
205
|
+
const candidates = [];
|
|
206
|
+
if (Number.isFinite(range.min)) candidates.push(range.min);
|
|
207
|
+
if (Number.isFinite(range.max)) candidates.push(range.max);
|
|
208
|
+
const gridPosition = (clamped - base) / step;
|
|
209
|
+
if (Number.isFinite(gridPosition)) {
|
|
210
|
+
addGridCandidate(candidates, Math.floor(gridPosition), range, base, step);
|
|
211
|
+
addGridCandidate(candidates, Math.ceil(gridPosition), range, base, step);
|
|
212
|
+
}
|
|
213
|
+
if (candidates.length === 0) return clamped;
|
|
214
|
+
let nearest = candidates[0];
|
|
215
|
+
let nearestDistance = Math.abs(clamped - nearest);
|
|
216
|
+
for (const candidate of candidates.slice(1)) {
|
|
217
|
+
const distance = Math.abs(clamped - candidate);
|
|
218
|
+
if (distance < nearestDistance || nearlyEqual(distance, nearestDistance) && candidate > nearest) {
|
|
219
|
+
nearest = candidate;
|
|
220
|
+
nearestDistance = distance;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return nearest;
|
|
224
|
+
}
|
|
225
|
+
function stepSteppedValue(current, count, range) {
|
|
226
|
+
const value = snapSteppedValue(current, range);
|
|
227
|
+
const distance = Math.abs(Math.trunc(count));
|
|
228
|
+
if (!Number.isFinite(distance) || distance === 0) return value;
|
|
229
|
+
const direction = Math.sign(count);
|
|
230
|
+
const adjacent = adjacentSteppedValue(value, direction, range);
|
|
231
|
+
const raw = adjacent + direction * (distance - 1) * effectiveStep(range.step);
|
|
232
|
+
return snapSteppedValue(raw, range);
|
|
233
|
+
}
|
|
234
|
+
function adjacentSteppedValue(current, direction, range) {
|
|
235
|
+
const step = effectiveStep(range.step);
|
|
236
|
+
const base = stepBase(range);
|
|
237
|
+
const candidates = [];
|
|
238
|
+
if (direction > 0) {
|
|
239
|
+
const position2 = (current - base) / step;
|
|
240
|
+
if (Number.isFinite(position2)) {
|
|
241
|
+
let gridIndex = Math.floor(position2) + 1;
|
|
242
|
+
let candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
243
|
+
if (candidate < current || nearlyEqual(candidate, current)) {
|
|
244
|
+
gridIndex += 1;
|
|
245
|
+
candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
246
|
+
}
|
|
247
|
+
if (candidate > current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
|
|
248
|
+
candidates.push(clamp(candidate, range));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return clamp(Math.min(...candidates), range);
|
|
252
|
+
}
|
|
253
|
+
const position = (current - base) / step;
|
|
254
|
+
if (Number.isFinite(position)) {
|
|
255
|
+
let gridIndex = Math.ceil(position) - 1;
|
|
256
|
+
let candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
257
|
+
if (candidate > current || nearlyEqual(candidate, current)) {
|
|
258
|
+
gridIndex -= 1;
|
|
259
|
+
candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
260
|
+
}
|
|
261
|
+
if (candidate < current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
|
|
262
|
+
candidates.push(clamp(candidate, range));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return clamp(Math.max(...candidates), range);
|
|
266
|
+
}
|
|
267
|
+
function addGridCandidate(candidates, index, range, base, step) {
|
|
268
|
+
const candidate = cleanGridValue(base + index * step, base, step);
|
|
269
|
+
if (within(candidate, range.min, range.max)) candidates.push(clamp(candidate, range));
|
|
270
|
+
}
|
|
271
|
+
function stepBase(range) {
|
|
272
|
+
if (range.base !== void 0 && Number.isFinite(range.base)) return range.base;
|
|
273
|
+
return Number.isFinite(range.min) ? range.min : 0;
|
|
274
|
+
}
|
|
275
|
+
function cleanGridValue(value, base, step) {
|
|
276
|
+
const precision = Math.max(decimalPlaces(base), decimalPlaces(step));
|
|
277
|
+
return precision <= 100 ? Number(value.toFixed(precision)) : value;
|
|
278
|
+
}
|
|
279
|
+
function decimalPlaces(value) {
|
|
280
|
+
const [coefficient = "", exponentText] = Math.abs(value).toString().toLowerCase().split("e");
|
|
281
|
+
const fractionLength = coefficient.split(".")[1]?.length ?? 0;
|
|
282
|
+
const exponent = exponentText === void 0 ? 0 : Number(exponentText);
|
|
283
|
+
return Math.max(0, fractionLength - exponent);
|
|
284
|
+
}
|
|
285
|
+
function within(value, min, max) {
|
|
286
|
+
return (value > min || nearlyEqual(value, min)) && (value < max || nearlyEqual(value, max));
|
|
287
|
+
}
|
|
288
|
+
function clamp(value, range) {
|
|
289
|
+
return Math.min(range.max, Math.max(range.min, value));
|
|
290
|
+
}
|
|
291
|
+
function nearlyEqual(left, right) {
|
|
292
|
+
const scale = Math.max(Math.abs(left), Math.abs(right));
|
|
293
|
+
return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) <= Number.EPSILON * scale;
|
|
294
|
+
}
|
|
295
|
+
function finiteFallback(min, max) {
|
|
296
|
+
if (Number.isFinite(min)) return min;
|
|
297
|
+
if (Number.isFinite(max)) return max;
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
78
301
|
// src/controllers/number_input_controller.ts
|
|
302
|
+
var OWNED_DISABLED = "data-number-input-disabled";
|
|
79
303
|
var NumberInputController = class _NumberInputController extends Controller {
|
|
80
304
|
static targets = ["input", "increment", "decrement"];
|
|
81
305
|
static values = {
|
|
@@ -85,7 +309,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
85
309
|
pageStep: { type: Number, default: 0 }
|
|
86
310
|
};
|
|
87
311
|
static actions = ["decrement", "increment", "onInput", "onKeydown"];
|
|
88
|
-
static events = ["change"];
|
|
312
|
+
static events = ["change", "reconcile"];
|
|
89
313
|
/** Delay (ms) a button must be held before auto-repeat starts. */
|
|
90
314
|
static #HOLD_DELAY_MS = 400;
|
|
91
315
|
/** Interval (ms) between auto-repeat steps once a hold has started. */
|
|
@@ -97,8 +321,18 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
97
321
|
* of poisoning the next legitimate click.
|
|
98
322
|
*/
|
|
99
323
|
static #SUPPRESS_RESET_MS = 250;
|
|
100
|
-
/** Aborts
|
|
101
|
-
#
|
|
324
|
+
/** Aborts global pointer guards on disconnect so none outlive the element. */
|
|
325
|
+
#globalGuards = null;
|
|
326
|
+
/** Per-target guards allow an old button to be released without touching its replacement. */
|
|
327
|
+
#buttonGuards = /* @__PURE__ */ new Map();
|
|
328
|
+
/** Tracks IME lifecycle on the current input, including confirming keys without a signal. */
|
|
329
|
+
#composition = new CompositionTracker();
|
|
330
|
+
/** Restores authored custom-spinbutton ARIA when a target leaves or the controller stops. */
|
|
331
|
+
#ariaValueNow = new AttributeLease("aria-valuenow");
|
|
332
|
+
#ariaValueMin = new AttributeLease("aria-valuemin");
|
|
333
|
+
#ariaValueMax = new AttributeLease("aria-valuemax");
|
|
334
|
+
/** Last reconciled or user-committed numeric value. */
|
|
335
|
+
#lastValue = null;
|
|
102
336
|
/** Timers for the hold delay and the suppress-reset safety net. */
|
|
103
337
|
#holdTimeouts = new SafeTimeout();
|
|
104
338
|
/** The running auto-repeat interval (one at a time). */
|
|
@@ -109,66 +343,134 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
109
343
|
#repeatedDuringHold = false;
|
|
110
344
|
/** True when the next `click` is the trailing one after a hold and must be ignored. */
|
|
111
345
|
#suppressNextClick = false;
|
|
346
|
+
/** Button and pointer that own the current hold, so other instances/pointers cannot end it. */
|
|
347
|
+
#holdButton = null;
|
|
348
|
+
#holdPointerId = null;
|
|
349
|
+
/** Collapses runtime range/step changes into one input reconciliation. */
|
|
350
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcile());
|
|
112
351
|
/** Normalizes any initial value and wires the focus/hold pointer guards. */
|
|
113
352
|
connect() {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
this.#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
window.addEventListener("pointercancel", this.#stopHold, { signal });
|
|
126
|
-
window.addEventListener("blur", this.#stopHold, { signal });
|
|
127
|
-
}
|
|
128
|
-
/** Releases the pointer guards and tears down every pending timer and hold state. */
|
|
353
|
+
this.#repaint.activate();
|
|
354
|
+
this.#globalGuards = new AbortController();
|
|
355
|
+
const { signal } = this.#globalGuards;
|
|
356
|
+
for (const button of this.incrementTargets) this.#wireButton(button, 1);
|
|
357
|
+
for (const button of this.decrementTargets) this.#wireButton(button, -1);
|
|
358
|
+
window.addEventListener("pointerup", this.#onPointerEnd, { signal });
|
|
359
|
+
window.addEventListener("pointercancel", this.#onPointerEnd, { signal });
|
|
360
|
+
window.addEventListener("blur", this.#onWindowBlur, { signal });
|
|
361
|
+
this.#reconcile();
|
|
362
|
+
}
|
|
363
|
+
/** Releases listeners, derived attributes, timers, and transient value state. */
|
|
129
364
|
disconnect() {
|
|
130
|
-
this.#
|
|
131
|
-
this.#
|
|
132
|
-
this.#
|
|
365
|
+
this.#repaint.cancel();
|
|
366
|
+
this.#composition.disconnect();
|
|
367
|
+
this.#globalGuards?.abort();
|
|
368
|
+
this.#globalGuards = null;
|
|
369
|
+
const buttons = /* @__PURE__ */ new Set([
|
|
370
|
+
...this.#buttonGuards.keys(),
|
|
371
|
+
...this.incrementTargets,
|
|
372
|
+
...this.decrementTargets
|
|
373
|
+
]);
|
|
374
|
+
for (const guard of this.#buttonGuards.values()) guard.abort();
|
|
375
|
+
this.#buttonGuards.clear();
|
|
376
|
+
for (const button of buttons) this.#releaseButtonState(button);
|
|
377
|
+
this.#stopHold(false);
|
|
133
378
|
this.#repeatedDuringHold = false;
|
|
134
379
|
this.#suppressNextClick = false;
|
|
135
380
|
this.#holdTimeouts.clearAll();
|
|
136
381
|
this.#holdIntervals.clearAll();
|
|
382
|
+
this.#ariaValueNow.returnAll();
|
|
383
|
+
this.#ariaValueMin.returnAll();
|
|
384
|
+
this.#ariaValueMax.returnAll();
|
|
385
|
+
this.#lastValue = null;
|
|
386
|
+
}
|
|
387
|
+
/** Reconciles the value against a minimum changed by application code or a Turbo morph. */
|
|
388
|
+
minValueChanged() {
|
|
389
|
+
this.#repaint.schedule();
|
|
390
|
+
}
|
|
391
|
+
/** Reconciles the value against a maximum changed by application code or a Turbo morph. */
|
|
392
|
+
maxValueChanged() {
|
|
393
|
+
this.#repaint.schedule();
|
|
394
|
+
}
|
|
395
|
+
/** Reconciles the value against a step changed by application code or a Turbo morph. */
|
|
396
|
+
stepValueChanged() {
|
|
397
|
+
this.#repaint.schedule();
|
|
398
|
+
}
|
|
399
|
+
/** Tracks and reconciles an input inserted or replaced at runtime. */
|
|
400
|
+
inputTargetConnected(input) {
|
|
401
|
+
this.#composition.observe(input);
|
|
402
|
+
this.#repaint.schedule();
|
|
403
|
+
}
|
|
404
|
+
/** Releases ARIA and composition state owned by an input that left the controller. */
|
|
405
|
+
inputTargetDisconnected(input) {
|
|
406
|
+
this.#composition.unobserve(input);
|
|
407
|
+
this.#releaseInputAria(input);
|
|
408
|
+
this.#stopHold(false);
|
|
409
|
+
this.#lastValue = null;
|
|
410
|
+
this.#repaint.schedule();
|
|
411
|
+
}
|
|
412
|
+
/** Wires focus preservation and hold behavior on a runtime increment target. */
|
|
413
|
+
incrementTargetConnected(button) {
|
|
414
|
+
this.#wireButton(button, 1);
|
|
415
|
+
this.#repaint.schedule();
|
|
416
|
+
}
|
|
417
|
+
/** Releases only the increment target that actually disconnected. */
|
|
418
|
+
incrementTargetDisconnected(button) {
|
|
419
|
+
this.#unwireButton(button);
|
|
420
|
+
this.#repaint.schedule();
|
|
421
|
+
}
|
|
422
|
+
/** Wires focus preservation and hold behavior on a runtime decrement target. */
|
|
423
|
+
decrementTargetConnected(button) {
|
|
424
|
+
this.#wireButton(button, -1);
|
|
425
|
+
this.#repaint.schedule();
|
|
426
|
+
}
|
|
427
|
+
/** Releases only the decrement target that actually disconnected. */
|
|
428
|
+
decrementTargetDisconnected(button) {
|
|
429
|
+
this.#unwireButton(button);
|
|
430
|
+
this.#repaint.schedule();
|
|
137
431
|
}
|
|
138
432
|
/** Increases by one step. Bound via `data-action` (click). */
|
|
139
433
|
increment() {
|
|
140
434
|
if (this.#consumeSuppressedClick()) return;
|
|
141
|
-
|
|
435
|
+
if (!this.hasInputTarget) return;
|
|
436
|
+
this.#commitStep(1);
|
|
142
437
|
this.inputTarget.focus();
|
|
143
438
|
}
|
|
144
439
|
/** Decreases by one step. Bound via `data-action` (click). */
|
|
145
440
|
decrement() {
|
|
146
441
|
if (this.#consumeSuppressedClick()) return;
|
|
147
|
-
|
|
442
|
+
if (!this.hasInputTarget) return;
|
|
443
|
+
this.#commitStep(-1);
|
|
148
444
|
this.inputTarget.focus();
|
|
149
445
|
}
|
|
150
446
|
/** Clamps and snaps a typed value. Bound via `data-action` (change). */
|
|
151
447
|
onInput() {
|
|
152
|
-
if (this.
|
|
448
|
+
if (!this.hasInputTarget) return;
|
|
449
|
+
if (this.inputTarget.value.trim() === "") {
|
|
450
|
+
this.#lastValue = null;
|
|
451
|
+
this.#reflectEmpty();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
153
454
|
this.#commit(this.#currentValue());
|
|
154
455
|
}
|
|
155
456
|
/** Keyboard stepping per the APG spinbutton model. */
|
|
156
457
|
onKeydown(event) {
|
|
458
|
+
if (!this.hasInputTarget) return;
|
|
459
|
+
if (this.#composition.isComposing(event)) return;
|
|
157
460
|
if (isReservedArrowChord(event)) return;
|
|
158
|
-
const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
|
|
159
461
|
let next = null;
|
|
160
462
|
switch (event.key) {
|
|
161
463
|
case "ArrowUp":
|
|
162
|
-
next = this.#currentValue()
|
|
464
|
+
next = stepSteppedValue(this.#currentValue(), 1, this.#steppedRange);
|
|
163
465
|
break;
|
|
164
466
|
case "ArrowDown":
|
|
165
|
-
next = this.#currentValue() - this
|
|
467
|
+
next = stepSteppedValue(this.#currentValue(), -1, this.#steppedRange);
|
|
166
468
|
break;
|
|
167
469
|
case "PageUp":
|
|
168
|
-
next = this.#currentValue() +
|
|
470
|
+
next = this.pageStepValue > 0 ? this.#currentValue() + this.pageStepValue : stepSteppedValue(this.#currentValue(), 10, this.#steppedRange);
|
|
169
471
|
break;
|
|
170
472
|
case "PageDown":
|
|
171
|
-
next = this.#currentValue() -
|
|
473
|
+
next = this.pageStepValue > 0 ? this.#currentValue() - this.pageStepValue : stepSteppedValue(this.#currentValue(), -10, this.#steppedRange);
|
|
172
474
|
break;
|
|
173
475
|
case "Home":
|
|
174
476
|
if (!Number.isFinite(this.minValue)) return;
|
|
@@ -189,31 +491,49 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
189
491
|
* hold; leaving the button while held stops it (the global listeners cover
|
|
190
492
|
* release/cancel/blur).
|
|
191
493
|
*/
|
|
192
|
-
#wireButton(button,
|
|
193
|
-
|
|
194
|
-
|
|
494
|
+
#wireButton(button, direction) {
|
|
495
|
+
if (this.#buttonGuards.has(button)) return;
|
|
496
|
+
const guard = new AbortController();
|
|
497
|
+
this.#buttonGuards.set(button, guard);
|
|
498
|
+
button.addEventListener("pointerdown", (event) => this.#armHold(event, button, direction), {
|
|
499
|
+
signal: guard.signal
|
|
195
500
|
});
|
|
196
|
-
button.addEventListener(
|
|
501
|
+
button.addEventListener(
|
|
502
|
+
"pointerleave",
|
|
503
|
+
(event) => {
|
|
504
|
+
if (this.#holdButton === button && this.#ownsPointer(event)) this.#stopHold();
|
|
505
|
+
},
|
|
506
|
+
{ signal: guard.signal }
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
/** Aborts one target's listeners and returns any state derived onto that button. */
|
|
510
|
+
#unwireButton(button) {
|
|
511
|
+
this.#buttonGuards.get(button)?.abort();
|
|
512
|
+
this.#buttonGuards.delete(button);
|
|
513
|
+
if (this.#holdButton === button) this.#stopHold(false);
|
|
514
|
+
this.#releaseButtonState(button);
|
|
197
515
|
}
|
|
198
516
|
/** Starts a hold: focus the input, then schedule the first repeat after a delay. */
|
|
199
|
-
#armHold(event, button,
|
|
517
|
+
#armHold(event, button, direction) {
|
|
200
518
|
const pointerButton = event.button;
|
|
201
519
|
if (typeof pointerButton === "number" && pointerButton !== 0) return;
|
|
202
|
-
if (button.disabled) return;
|
|
520
|
+
if (button.disabled || !this.hasInputTarget) return;
|
|
203
521
|
event.preventDefault();
|
|
204
522
|
this.inputTarget.focus();
|
|
205
|
-
this.#stopHold();
|
|
523
|
+
this.#stopHold(false);
|
|
206
524
|
this.#holdActive = true;
|
|
525
|
+
this.#holdButton = button;
|
|
526
|
+
this.#holdPointerId = this.#pointerId(event);
|
|
207
527
|
this.#repeatedDuringHold = false;
|
|
208
528
|
this.#suppressNextClick = false;
|
|
209
529
|
this.#holdTimeouts.set(() => {
|
|
210
|
-
if (!this.#
|
|
530
|
+
if (!this.#commitStep(direction)) {
|
|
211
531
|
this.#stopHold();
|
|
212
532
|
return;
|
|
213
533
|
}
|
|
214
534
|
this.#repeatedDuringHold = true;
|
|
215
535
|
this.#holdIntervals.set(() => {
|
|
216
|
-
if (!this.#
|
|
536
|
+
if (!this.#commitStep(direction)) this.#stopHold();
|
|
217
537
|
}, _NumberInputController.#HOLD_REPEAT_MS);
|
|
218
538
|
}, _NumberInputController.#HOLD_DELAY_MS);
|
|
219
539
|
}
|
|
@@ -222,18 +542,39 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
222
542
|
* trailing synthetic `click` must be ignored, so it is suppressed until the
|
|
223
543
|
* click consumes it or a short safety-net timeout clears it.
|
|
224
544
|
*/
|
|
225
|
-
#stopHold =
|
|
545
|
+
#stopHold(suppressTrailingClick = true) {
|
|
226
546
|
if (!this.#holdActive) return;
|
|
227
547
|
this.#holdActive = false;
|
|
548
|
+
this.#holdButton = null;
|
|
549
|
+
this.#holdPointerId = null;
|
|
228
550
|
this.#holdTimeouts.clearAll();
|
|
229
551
|
this.#holdIntervals.clearAll();
|
|
230
|
-
if (this.#repeatedDuringHold) {
|
|
552
|
+
if (suppressTrailingClick && this.#repeatedDuringHold) {
|
|
231
553
|
this.#suppressNextClick = true;
|
|
232
554
|
this.#holdTimeouts.set(() => {
|
|
233
555
|
this.#suppressNextClick = false;
|
|
234
556
|
}, _NumberInputController.#SUPPRESS_RESET_MS);
|
|
557
|
+
} else if (!suppressTrailingClick) {
|
|
558
|
+
this.#suppressNextClick = false;
|
|
235
559
|
}
|
|
560
|
+
}
|
|
561
|
+
/** Ends a hold only for its initiating pointer (legacy/synthetic events remain accepted). */
|
|
562
|
+
#onPointerEnd = (event) => {
|
|
563
|
+
if (this.#ownsPointer(event)) this.#stopHold();
|
|
236
564
|
};
|
|
565
|
+
/** Window deactivation is global and always ends the active hold. */
|
|
566
|
+
#onWindowBlur = () => {
|
|
567
|
+
this.#stopHold();
|
|
568
|
+
};
|
|
569
|
+
/** Extracts pointer identity when the runtime event exposes it. */
|
|
570
|
+
#pointerId(event) {
|
|
571
|
+
return event.pointerId ?? null;
|
|
572
|
+
}
|
|
573
|
+
/** Whether `event` belongs to the active pointer, with a synthetic-event fallback. */
|
|
574
|
+
#ownsPointer(event) {
|
|
575
|
+
const pointerId = this.#pointerId(event);
|
|
576
|
+
return this.#holdActive && (this.#holdPointerId === null || pointerId === null || pointerId === this.#holdPointerId);
|
|
577
|
+
}
|
|
237
578
|
/** Consumes a pending trailing-click suppression; returns true if the click was swallowed. */
|
|
238
579
|
#consumeSuppressedClick() {
|
|
239
580
|
if (!this.#suppressNextClick) return false;
|
|
@@ -242,36 +583,80 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
242
583
|
return true;
|
|
243
584
|
}
|
|
244
585
|
/**
|
|
245
|
-
* Normalizes `raw`, reflects it, and dispatches `
|
|
246
|
-
*
|
|
586
|
+
* Normalizes `raw`, reflects it, and dispatches `{ value: number }` only when
|
|
587
|
+
* the committed numeric value differs from the last reconciled value.
|
|
247
588
|
*
|
|
248
589
|
* @returns Whether the value changed (drives the auto-repeat's bound stop).
|
|
249
590
|
*/
|
|
250
591
|
#commit(raw) {
|
|
251
592
|
const value = this.#normalize(raw);
|
|
252
|
-
const changed = this
|
|
593
|
+
const changed = this.#lastValue === null || value !== this.#lastValue;
|
|
253
594
|
this.#write(value);
|
|
595
|
+
this.#lastValue = value;
|
|
254
596
|
if (changed) this.dispatch("change", { detail: { value } });
|
|
255
597
|
return changed;
|
|
256
598
|
}
|
|
257
|
-
/**
|
|
258
|
-
#
|
|
259
|
-
this.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
599
|
+
/** Commits an adjacent endpoint/grid value and reports whether it moved. */
|
|
600
|
+
#commitStep(count) {
|
|
601
|
+
if (!this.hasInputTarget) return false;
|
|
602
|
+
return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Reflects the current value after a range or step morph, reporting a moved
|
|
606
|
+
* value as `reconcile`.
|
|
607
|
+
*
|
|
608
|
+
* @stimeoRenderRoot
|
|
609
|
+
*/
|
|
610
|
+
#reconcile() {
|
|
611
|
+
if (!this.hasInputTarget) {
|
|
612
|
+
this.#lastValue = null;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (this.inputTarget.value.trim() !== "") {
|
|
616
|
+
const previous = this.#lastValue;
|
|
617
|
+
const value = this.#normalize(this.#currentValue());
|
|
618
|
+
this.#write(value);
|
|
619
|
+
this.#lastValue = value;
|
|
620
|
+
if (previous !== null && value !== previous) {
|
|
621
|
+
this.dispatch("reconcile", { detail: { value } });
|
|
267
622
|
}
|
|
623
|
+
} else {
|
|
624
|
+
this.#lastValue = null;
|
|
625
|
+
this.#reflectEmpty();
|
|
268
626
|
}
|
|
627
|
+
}
|
|
628
|
+
/** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
|
|
629
|
+
#write(value) {
|
|
630
|
+
const input = this.inputTarget;
|
|
631
|
+
input.value = String(value);
|
|
632
|
+
this.#syncInputAria(input, value);
|
|
269
633
|
this.#updateButtons(value);
|
|
270
634
|
}
|
|
635
|
+
/** Reflects a blank value without inventing a numeric `aria-valuenow`. */
|
|
636
|
+
#reflectEmpty() {
|
|
637
|
+
this.#syncInputAria(this.inputTarget, null);
|
|
638
|
+
this.#updateButtons(this.#currentValue());
|
|
639
|
+
}
|
|
640
|
+
/** Synchronizes controller-derived ARIA while preserving authored teardown values. */
|
|
641
|
+
#syncInputAria(input, value) {
|
|
642
|
+
if (input.getAttribute("role") !== "spinbutton") {
|
|
643
|
+
this.#releaseInputAria(input);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this.#ariaValueNow.write(input, value === null ? null : String(value));
|
|
647
|
+
this.#ariaValueMin.write(input, Number.isFinite(this.minValue) ? String(this.minValue) : null);
|
|
648
|
+
this.#ariaValueMax.write(input, Number.isFinite(this.maxValue) ? String(this.maxValue) : null);
|
|
649
|
+
}
|
|
650
|
+
/** Returns all custom-spinbutton ARIA leased on `input`. */
|
|
651
|
+
#releaseInputAria(input) {
|
|
652
|
+
this.#ariaValueNow.return(input);
|
|
653
|
+
this.#ariaValueMin.return(input);
|
|
654
|
+
this.#ariaValueMax.return(input);
|
|
655
|
+
}
|
|
271
656
|
/** Disables a step button at its bound, returning focus to the input first. */
|
|
272
657
|
#updateButtons(value) {
|
|
273
|
-
|
|
274
|
-
|
|
658
|
+
for (const button of this.incrementTargets) this.#toggleButton(button, value < this.maxValue);
|
|
659
|
+
for (const button of this.decrementTargets) this.#toggleButton(button, value > this.minValue);
|
|
275
660
|
}
|
|
276
661
|
/**
|
|
277
662
|
* Enables or disables a step button at its bound, never disabling one while it
|
|
@@ -281,16 +666,22 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
281
666
|
*/
|
|
282
667
|
#toggleButton(button, enabled) {
|
|
283
668
|
if (enabled) {
|
|
284
|
-
if (button.hasAttribute(
|
|
669
|
+
if (button.hasAttribute(OWNED_DISABLED)) {
|
|
285
670
|
button.disabled = false;
|
|
286
|
-
button.removeAttribute(
|
|
671
|
+
button.removeAttribute(OWNED_DISABLED);
|
|
287
672
|
}
|
|
288
673
|
return;
|
|
289
674
|
}
|
|
290
675
|
if (button.disabled) return;
|
|
291
|
-
if (document.activeElement === button) this.inputTarget.focus();
|
|
676
|
+
if (document.activeElement === button && this.hasInputTarget) this.inputTarget.focus();
|
|
292
677
|
button.disabled = true;
|
|
293
|
-
button.setAttribute(
|
|
678
|
+
button.setAttribute(OWNED_DISABLED, "");
|
|
679
|
+
}
|
|
680
|
+
/** Returns `disabled` only when its explicit marker still identifies controller ownership. */
|
|
681
|
+
#releaseButtonState(button) {
|
|
682
|
+
if (!button.hasAttribute(OWNED_DISABLED)) return;
|
|
683
|
+
button.disabled = false;
|
|
684
|
+
button.removeAttribute(OWNED_DISABLED);
|
|
294
685
|
}
|
|
295
686
|
/** The current numeric value, falling back to a finite min (else 0) when blank. */
|
|
296
687
|
#currentValue() {
|
|
@@ -300,11 +691,11 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
300
691
|
}
|
|
301
692
|
/** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
|
|
302
693
|
#normalize(raw) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return
|
|
694
|
+
return snapSteppedValue(raw, this.#steppedRange);
|
|
695
|
+
}
|
|
696
|
+
/** Shared range configuration; finite endpoints remain allowed off the grid. */
|
|
697
|
+
get #steppedRange() {
|
|
698
|
+
return { min: this.minValue, max: this.maxValue, step: this.stepValue };
|
|
308
699
|
}
|
|
309
700
|
};
|
|
310
701
|
|