stimeo-ui 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +178 -0
- data/README.md +120 -0
- data/dist/cable/index.js +63 -15
- data/dist/controllers/accordion_controller.js +98 -9
- data/dist/controllers/announcer_controller.js +8 -1
- data/dist/controllers/auto_submit_controller.js +43 -2
- data/dist/controllers/avatar_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +9 -8
- data/dist/controllers/bulk_select_controller.js +7 -6
- data/dist/controllers/calendar_controller.js +323 -123
- data/dist/controllers/carousel_controller.js +263 -38
- data/dist/controllers/character_counter_controller.js +40 -2
- data/dist/controllers/checkbox_controller.js +54 -10
- data/dist/controllers/clipboard_controller.js +63 -5
- data/dist/controllers/collapsible_controller.js +99 -14
- data/dist/controllers/color_picker_controller.js +80 -34
- data/dist/controllers/combobox_controller.js +106 -16
- data/dist/controllers/command_palette_controller.js +35 -3
- data/dist/controllers/conditional_fields_controller.js +56 -11
- data/dist/controllers/confirm_controller.js +3 -0
- data/dist/controllers/context_menu_controller.js +32 -12
- data/dist/controllers/countdown_controller.js +129 -26
- data/dist/controllers/currency_input_controller.js +205 -58
- data/dist/controllers/data_grid_controller.js +195 -29
- data/dist/controllers/date_range_picker_controller.js +139 -27
- data/dist/controllers/dialog_controller.js +35 -8
- data/dist/controllers/direct_upload_controller.js +22 -4
- data/dist/controllers/dirty_form_controller.js +14 -1
- data/dist/controllers/dismissible_controller.js +1 -0
- data/dist/controllers/drawer_controller.js +54 -19
- data/dist/controllers/dropdown_controller.js +36 -9
- data/dist/controllers/editable_controller.js +34 -0
- data/dist/controllers/file_dropzone_controller.js +144 -51
- data/dist/controllers/filter_controller.js +20 -6
- data/dist/controllers/flash_controller.js +305 -46
- data/dist/controllers/focus_controller.js +1 -0
- data/dist/controllers/form_field_controller.js +7 -5
- data/dist/controllers/form_validation_controller.js +19 -13
- data/dist/controllers/frame_loading_controller.js +16 -2
- data/dist/controllers/highlight_controller.js +19 -2
- data/dist/controllers/hover_card_controller.js +40 -14
- data/dist/controllers/idle_controller.js +90 -5
- data/dist/controllers/input_mask_controller.js +65 -9
- data/dist/controllers/intersection_controller.js +3 -0
- data/dist/controllers/lazy_frame_controller.js +11 -2
- data/dist/controllers/listbox_controller.js +203 -45
- data/dist/controllers/masonry_controller.js +0 -2
- data/dist/controllers/menu_controller.js +45 -16
- data/dist/controllers/menubar_controller.js +58 -24
- data/dist/controllers/meter_controller.js +9 -5
- data/dist/controllers/multi_select_controller.js +278 -104
- data/dist/controllers/navigation_menu_controller.js +48 -15
- data/dist/controllers/nested_form_controller.js +37 -8
- data/dist/controllers/network_status_controller.js +9 -1
- data/dist/controllers/number_input_controller.js +124 -21
- data/dist/controllers/optimistic_controller.js +14 -1
- data/dist/controllers/otp_controller.js +198 -55
- data/dist/controllers/overflow_indicator_controller.js +84 -13
- data/dist/controllers/overflow_menu_controller.js +112 -41
- data/dist/controllers/pagination_controller.js +74 -28
- data/dist/controllers/password_reveal_controller.js +59 -2
- data/dist/controllers/persist_controller.js +30 -8
- data/dist/controllers/pointer_drag_controller.js +131 -52
- data/dist/controllers/popover_controller.js +45 -11
- data/dist/controllers/portal_controller.js +6 -2
- data/dist/controllers/preview_guard_controller.js +16 -1
- data/dist/controllers/progress_controller.js +8 -4
- data/dist/controllers/radio_group_controller.js +42 -17
- data/dist/controllers/range_slider_controller.js +88 -42
- data/dist/controllers/rating_controller.js +39 -15
- data/dist/controllers/read_more_controller.js +69 -2
- data/dist/controllers/resizable_controller.js +82 -22
- data/dist/controllers/scroll_area_controller.js +5 -1
- data/dist/controllers/scroll_visibility_controller.js +0 -1
- data/dist/controllers/scrollspy_controller.js +6 -0
- data/dist/controllers/separator_controller.js +66 -37
- data/dist/controllers/sidebar_controller.js +77 -18
- data/dist/controllers/skeleton_controller.js +6 -1
- data/dist/controllers/slider_controller.js +82 -47
- data/dist/controllers/smart_sticky_header_controller.js +11 -1
- data/dist/controllers/sortable_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +10 -2
- data/dist/controllers/step_indicator_controller.js +18 -17
- data/dist/controllers/stepper_controller.js +101 -19
- data/dist/controllers/stick_to_bottom_controller.js +75 -5
- data/dist/controllers/submit_once_controller.js +16 -3
- data/dist/controllers/switch_controller.js +101 -10
- data/dist/controllers/tabs_controller.js +21 -2
- data/dist/controllers/tags_input_controller.js +209 -59
- data/dist/controllers/theme_controller.js +64 -14
- data/dist/controllers/time_picker_controller.js +23 -8
- data/dist/controllers/toast_controller.js +337 -54
- data/dist/controllers/toggle_group_controller.js +159 -23
- data/dist/controllers/toolbar_controller.js +32 -0
- data/dist/controllers/tooltip_controller.js +39 -13
- data/dist/controllers/transition_controller.js +4 -0
- data/dist/controllers/tree_view_controller.js +169 -16
- data/dist/index.js +4588 -1535
- data/dist/positioning/index.js +2 -0
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -262,6 +262,10 @@ var FrameLoadingController = class extends Controller {
|
|
|
262
262
|
this.#floor.cancel();
|
|
263
263
|
if (!this.#loading) this.#begin();
|
|
264
264
|
};
|
|
265
|
+
/**
|
|
266
|
+
* @stimeoRuntimeOnly `minDuration` holds the loading state at least this long for the one load
|
|
267
|
+
* that ended.
|
|
268
|
+
*/
|
|
265
269
|
#onEnd = () => {
|
|
266
270
|
if (!this.#loading) return;
|
|
267
271
|
this.#floor.schedule(this.minDurationValue, () => this.#finish());
|
|
@@ -363,7 +367,12 @@ var FrameLoadingController = class extends Controller {
|
|
|
363
367
|
this.#revealedOverlay = this.overlayTarget;
|
|
364
368
|
this.overlayTarget.hidden = false;
|
|
365
369
|
}
|
|
366
|
-
/**
|
|
370
|
+
/**
|
|
371
|
+
* Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat.
|
|
372
|
+
*
|
|
373
|
+
* @stimeoRuntimeOnly `announceText` words the one announcement of this load and `restoreFocus`
|
|
374
|
+
* decides whether focus leaves the frame it covers.
|
|
375
|
+
*/
|
|
367
376
|
#begin() {
|
|
368
377
|
this.#loading = true;
|
|
369
378
|
this.#floor.begin();
|
|
@@ -376,7 +385,12 @@ var FrameLoadingController = class extends Controller {
|
|
|
376
385
|
this.dispatch("start", { detail: {} });
|
|
377
386
|
announce(fillTemplate(this.announceTextValue, {}));
|
|
378
387
|
}
|
|
379
|
-
/**
|
|
388
|
+
/**
|
|
389
|
+
* Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus.
|
|
390
|
+
*
|
|
391
|
+
* @stimeoRuntimeOnly `announceReadyText` words the one announcement of this finished load and
|
|
392
|
+
* `restoreFocus` decides whether focus goes back.
|
|
393
|
+
*/
|
|
380
394
|
#finish() {
|
|
381
395
|
this.#loading = false;
|
|
382
396
|
this.#rewindHooks();
|
|
@@ -103,7 +103,20 @@ function prefersReducedMotion() {
|
|
|
103
103
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// src/utils/transient_hooks.ts
|
|
107
|
+
var TransientHooks = class {
|
|
108
|
+
#attributes;
|
|
109
|
+
constructor(options) {
|
|
110
|
+
this.#attributes = options.attributes;
|
|
111
|
+
}
|
|
112
|
+
/** Drops the declared hooks from `element`; what it carries besides stays. */
|
|
113
|
+
reset(element) {
|
|
114
|
+
for (const attribute of this.#attributes) element.removeAttribute(attribute);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
106
118
|
// src/controllers/highlight_controller.ts
|
|
119
|
+
var TRANSIENT = new TransientHooks({ attributes: ["data-highlight"] });
|
|
107
120
|
var hookOwners = /* @__PURE__ */ new WeakMap();
|
|
108
121
|
var HighlightController = class extends Controller {
|
|
109
122
|
static values = {
|
|
@@ -137,7 +150,7 @@ var HighlightController = class extends Controller {
|
|
|
137
150
|
/** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
|
|
138
151
|
#clearArrivedHook(el) {
|
|
139
152
|
if (hookOwners.get(el) === this) hookOwners.delete(el);
|
|
140
|
-
|
|
153
|
+
TRANSIENT.reset(el);
|
|
141
154
|
}
|
|
142
155
|
/** Highlights every element child added by a childList mutation. */
|
|
143
156
|
#onMutations(mutations) {
|
|
@@ -147,7 +160,11 @@ var HighlightController = class extends Controller {
|
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
}
|
|
150
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion).
|
|
165
|
+
*
|
|
166
|
+
* @stimeoRuntimeOnly `duration` is the length of the one emphasis this call arms.
|
|
167
|
+
*/
|
|
151
168
|
#highlight(el) {
|
|
152
169
|
if (prefersReducedMotion()) return;
|
|
153
170
|
this.#releasePending(el);
|
|
@@ -164,6 +164,16 @@ function isScrollable(element) {
|
|
|
164
164
|
return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// src/utils/state_reason.ts
|
|
168
|
+
var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
|
|
169
|
+
var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
|
|
170
|
+
function stateReasonFor(event) {
|
|
171
|
+
if (!event) return "api";
|
|
172
|
+
if (FOCUS_EVENTS.has(event.type)) return "focus";
|
|
173
|
+
if (POINTER_EVENTS.has(event.type)) return "pointer";
|
|
174
|
+
return "user";
|
|
175
|
+
}
|
|
176
|
+
|
|
167
177
|
// src/controllers/hover_card_controller.ts
|
|
168
178
|
var HoverCardController = class extends Controller {
|
|
169
179
|
static targets = ["trigger", "card"];
|
|
@@ -173,6 +183,7 @@ var HoverCardController = class extends Controller {
|
|
|
173
183
|
closeOnScroll: { type: Boolean, default: false }
|
|
174
184
|
};
|
|
175
185
|
static actions = ["close", "open"];
|
|
186
|
+
static events = ["close", "open"];
|
|
176
187
|
/** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
|
|
177
188
|
#timers = new SafeTimeout();
|
|
178
189
|
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
@@ -181,14 +192,18 @@ var HoverCardController = class extends Controller {
|
|
|
181
192
|
#pendingClose = null;
|
|
182
193
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
183
194
|
#stopScrollDismiss = null;
|
|
195
|
+
/** Whether state moves are reported: set once `connect()` settled the baseline. */
|
|
196
|
+
#reporting = false;
|
|
184
197
|
/** Starts closed and discards any stale pending state from a prior connection. */
|
|
185
198
|
connect() {
|
|
186
199
|
this.#cancelOpen();
|
|
187
200
|
this.#cancelClose();
|
|
188
|
-
this.#conceal();
|
|
201
|
+
this.#conceal("api");
|
|
202
|
+
this.#reporting = true;
|
|
189
203
|
}
|
|
190
204
|
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
191
205
|
disconnect() {
|
|
206
|
+
this.#reporting = false;
|
|
192
207
|
this.#cancelOpen();
|
|
193
208
|
this.#cancelClose();
|
|
194
209
|
this.#escapeLayer.deactivate();
|
|
@@ -196,16 +211,17 @@ var HoverCardController = class extends Controller {
|
|
|
196
211
|
this.#stopScrollDismiss = null;
|
|
197
212
|
}
|
|
198
213
|
/** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
|
|
199
|
-
open() {
|
|
214
|
+
open(event) {
|
|
215
|
+
const reason = stateReasonFor(event);
|
|
200
216
|
this.#cancelClose();
|
|
201
217
|
if (this.#isOpen || this.#pendingOpen !== null) return;
|
|
202
218
|
if (this.openDelayValue <= 0) {
|
|
203
|
-
this.#reveal();
|
|
219
|
+
this.#reveal(reason);
|
|
204
220
|
return;
|
|
205
221
|
}
|
|
206
222
|
this.#pendingOpen = this.#timers.set(() => {
|
|
207
223
|
this.#pendingOpen = null;
|
|
208
|
-
this.#reveal();
|
|
224
|
+
this.#reveal(reason);
|
|
209
225
|
}, this.openDelayValue);
|
|
210
226
|
}
|
|
211
227
|
/**
|
|
@@ -214,28 +230,37 @@ var HoverCardController = class extends Controller {
|
|
|
214
230
|
* (e.g. a link in the card) and, if so, aborts the close — covering keyboard
|
|
215
231
|
* traversal that the pointer-only hoverable bridge cannot.
|
|
216
232
|
*/
|
|
217
|
-
close() {
|
|
233
|
+
close(event) {
|
|
234
|
+
const reason = stateReasonFor(event);
|
|
218
235
|
this.#cancelOpen();
|
|
219
236
|
if (!this.#isOpen || this.#pendingClose !== null) return;
|
|
220
237
|
this.#pendingClose = this.#timers.set(() => {
|
|
221
238
|
this.#pendingClose = null;
|
|
222
239
|
if (this.element.contains(document.activeElement)) return;
|
|
223
|
-
this.#conceal();
|
|
240
|
+
this.#conceal(reason);
|
|
224
241
|
}, this.closeDelayValue);
|
|
225
242
|
}
|
|
226
|
-
/**
|
|
227
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Reveals the card, reflects state, reports a move, and joins the Escape stack.
|
|
245
|
+
*
|
|
246
|
+
* @stimeoRuntimeOnly `closeOnScroll` decides whether this reveal wires the scroll dismissal; what
|
|
247
|
+
* is shown does not depend on it.
|
|
248
|
+
*/
|
|
249
|
+
#reveal(reason) {
|
|
228
250
|
if (!this.hasCardTarget) return;
|
|
251
|
+
const was = this.#isOpen;
|
|
229
252
|
this.cardTarget.hidden = false;
|
|
230
253
|
this.cardTarget.setAttribute("data-state", "open");
|
|
231
254
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
232
|
-
this.#
|
|
255
|
+
if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
|
|
256
|
+
this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss("escape") });
|
|
233
257
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
234
|
-
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
258
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss("scroll"));
|
|
235
259
|
}
|
|
236
260
|
}
|
|
237
|
-
/** Hides the card, reflects state, and leaves the Escape stack
|
|
238
|
-
#conceal() {
|
|
261
|
+
/** Hides the card, reflects state, reports a move, and leaves the Escape stack. */
|
|
262
|
+
#conceal(reason) {
|
|
263
|
+
const was = this.#isOpen;
|
|
239
264
|
this.#escapeLayer.deactivate();
|
|
240
265
|
this.#stopScrollDismiss?.();
|
|
241
266
|
this.#stopScrollDismiss = null;
|
|
@@ -244,12 +269,13 @@ var HoverCardController = class extends Controller {
|
|
|
244
269
|
this.cardTarget.setAttribute("data-state", "closed");
|
|
245
270
|
}
|
|
246
271
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
272
|
+
if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
|
|
247
273
|
}
|
|
248
274
|
/** Cancels pending timers and conceals immediately (shared Escape path). */
|
|
249
|
-
#dismiss() {
|
|
275
|
+
#dismiss(reason) {
|
|
250
276
|
this.#cancelOpen();
|
|
251
277
|
this.#cancelClose();
|
|
252
|
-
this.#conceal();
|
|
278
|
+
this.#conceal(reason);
|
|
253
279
|
}
|
|
254
280
|
/** Cancels any pending open timer. */
|
|
255
281
|
#cancelOpen() {
|
|
@@ -55,6 +55,47 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// src/utils/state_regions.ts
|
|
59
|
+
var StateRegions = class {
|
|
60
|
+
#whenTrue;
|
|
61
|
+
#whenFalse;
|
|
62
|
+
#taken = /* @__PURE__ */ new WeakSet();
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.#whenTrue = options.whenTrue;
|
|
65
|
+
this.#whenFalse = options.whenFalse ?? null;
|
|
66
|
+
}
|
|
67
|
+
/** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
|
|
68
|
+
reflect(host, isTrue) {
|
|
69
|
+
const shown = this.#inside(host, this.#whenTrue());
|
|
70
|
+
if (!this.#whenFalse) {
|
|
71
|
+
for (const region of shown) this.#write(region, !isTrue);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const hiddenSide = this.#inside(host, this.#whenFalse());
|
|
75
|
+
if (shown.length === 0 || hiddenSide.length === 0) {
|
|
76
|
+
for (const region of shown) this.#give(region);
|
|
77
|
+
for (const region of hiddenSide) this.#give(region);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const region of shown) this.#write(region, !isTrue);
|
|
81
|
+
for (const region of hiddenSide) this.#write(region, isTrue);
|
|
82
|
+
}
|
|
83
|
+
/** Writes `hidden` where it moves, noting which regions it takes out of view. */
|
|
84
|
+
#write(region, hidden) {
|
|
85
|
+
if (hidden) this.#taken.add(region);
|
|
86
|
+
else this.#taken.delete(region);
|
|
87
|
+
if (region.hidden !== hidden) region.hidden = hidden;
|
|
88
|
+
}
|
|
89
|
+
/** Returns a region this instance hid; one it never hid keeps what it carries. */
|
|
90
|
+
#give(region) {
|
|
91
|
+
if (this.#taken.delete(region)) region.hidden = false;
|
|
92
|
+
}
|
|
93
|
+
/** The declared regions that sit within `host`, which may be the host itself. */
|
|
94
|
+
#inside(host, regions) {
|
|
95
|
+
return regions.filter((region) => host.contains(region));
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
58
99
|
// src/utils/string_list.ts
|
|
59
100
|
function parseStringList(raw, fallback = []) {
|
|
60
101
|
const text = raw.trim();
|
|
@@ -87,7 +128,12 @@ var IdleController = class extends Controller {
|
|
|
87
128
|
// runs, so one malformed attribute would stop the detector connecting.
|
|
88
129
|
events: { type: String, default: "" }
|
|
89
130
|
};
|
|
131
|
+
static targets = ["prompt", "idle"];
|
|
90
132
|
static events = ["prompt", "idle", "active"];
|
|
133
|
+
/** The regions of the warning window, revealed alongside `data-prompt`. */
|
|
134
|
+
#promptRegions = new StateRegions({ whenTrue: () => this.promptTargets });
|
|
135
|
+
/** The regions of the elapsed timeout, revealed alongside `data-idle`. */
|
|
136
|
+
#idleRegions = new StateRegions({ whenTrue: () => this.idleTargets });
|
|
91
137
|
#timeouts = new SafeTimeout();
|
|
92
138
|
#idle = false;
|
|
93
139
|
#prompted = false;
|
|
@@ -104,7 +150,7 @@ var IdleController = class extends Controller {
|
|
|
104
150
|
if (this.#idle || this.#prompted) {
|
|
105
151
|
this.#idle = false;
|
|
106
152
|
this.#prompted = false;
|
|
107
|
-
this
|
|
153
|
+
this.#reflect();
|
|
108
154
|
this.dispatch("active", { detail: {} });
|
|
109
155
|
this.#arm();
|
|
110
156
|
}
|
|
@@ -115,7 +161,7 @@ var IdleController = class extends Controller {
|
|
|
115
161
|
connect() {
|
|
116
162
|
this.#idle = false;
|
|
117
163
|
this.#prompted = false;
|
|
118
|
-
this
|
|
164
|
+
this.#reflect();
|
|
119
165
|
this.#boundEvents = parseStringList(this.eventsValue, DEFAULT_ACTIVITY_EVENTS);
|
|
120
166
|
for (const type of this.#boundEvents) {
|
|
121
167
|
document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
|
|
@@ -131,7 +177,11 @@ var IdleController = class extends Controller {
|
|
|
131
177
|
document.removeEventListener("visibilitychange", this.#onVisibility);
|
|
132
178
|
this.#timeouts.clearAll();
|
|
133
179
|
}
|
|
134
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* Schedules the prompt and idle checks from the current activity baseline.
|
|
182
|
+
*
|
|
183
|
+
* @stimeoRuntimeOnly `timeout` and `promptBefore` time the checks this call arms.
|
|
184
|
+
*/
|
|
135
185
|
#arm() {
|
|
136
186
|
this.#timeouts.clearAll();
|
|
137
187
|
this.#lastActivity = Date.now();
|
|
@@ -145,6 +195,9 @@ var IdleController = class extends Controller {
|
|
|
145
195
|
* Idle-timer callback: go idle only if there has genuinely been no activity for
|
|
146
196
|
* `timeout`; otherwise reschedule for the remaining time. This lets activity events
|
|
147
197
|
* stay O(1) (a timestamp write) while the deadline still tracks the last activity.
|
|
198
|
+
*
|
|
199
|
+
* @stimeoRuntimeOnly `timeout` sets the deadline this one check compares against; the phase it
|
|
200
|
+
* shows follows the elapsed time.
|
|
148
201
|
*/
|
|
149
202
|
#checkIdle() {
|
|
150
203
|
const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);
|
|
@@ -154,19 +207,51 @@ var IdleController = class extends Controller {
|
|
|
154
207
|
}
|
|
155
208
|
this.#idle = true;
|
|
156
209
|
this.#prompted = false;
|
|
157
|
-
this
|
|
210
|
+
this.#reflect();
|
|
158
211
|
this.dispatch("idle", { detail: {} });
|
|
159
212
|
}
|
|
160
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Prompt-timer callback: warn at `promptBefore` before the idle deadline. A window
|
|
215
|
+
* narrowed while the cycle runs can push the warning onto the deadline or past it,
|
|
216
|
+
* and the phase it belongs to is the one before idle, so a lapsed cycle keeps the
|
|
217
|
+
* phase it reached. The next window opens with the cycle that activity arms.
|
|
218
|
+
*
|
|
219
|
+
* @stimeoRuntimeOnly `timeout` and `promptBefore` set the deadlines this one check compares
|
|
220
|
+
* against; the phase it shows follows the elapsed time.
|
|
221
|
+
*/
|
|
161
222
|
#checkPrompt() {
|
|
223
|
+
if (this.#idle) return;
|
|
162
224
|
const remaining = this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);
|
|
163
225
|
if (remaining > 0) {
|
|
164
226
|
this.#timeouts.set(() => this.#checkPrompt(), remaining);
|
|
165
227
|
return;
|
|
166
228
|
}
|
|
167
229
|
this.#prompted = true;
|
|
230
|
+
this.#reflect();
|
|
168
231
|
this.dispatch("prompt", { detail: { remaining: this.promptBeforeValue } });
|
|
169
232
|
}
|
|
233
|
+
/** Applies the current phase to a warning region inserted or replaced at runtime. */
|
|
234
|
+
promptTargetConnected() {
|
|
235
|
+
this.#promptRegions.reflect(this.element, this.#prompted);
|
|
236
|
+
}
|
|
237
|
+
/** Applies the current phase to an idle region inserted or replaced at runtime. */
|
|
238
|
+
idleTargetConnected() {
|
|
239
|
+
this.#idleRegions.reflect(this.element, this.#idle);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Writes the phase to the element and to the declared regions. Both hooks and both
|
|
243
|
+
* regions are a pure function of the two flags, so every place that moves a flag ends
|
|
244
|
+
* here and the markup can never disagree with the phase the detector is in.
|
|
245
|
+
*/
|
|
246
|
+
#reflect() {
|
|
247
|
+
const { element } = this;
|
|
248
|
+
if (this.#prompted) element.setAttribute("data-prompt", "true");
|
|
249
|
+
else element.removeAttribute("data-prompt");
|
|
250
|
+
if (this.#idle) element.setAttribute("data-idle", "true");
|
|
251
|
+
else element.removeAttribute("data-idle");
|
|
252
|
+
this.#promptRegions.reflect(element, this.#prompted);
|
|
253
|
+
this.#idleRegions.reflect(element, this.#idle);
|
|
254
|
+
}
|
|
170
255
|
};
|
|
171
256
|
|
|
172
257
|
export { IdleController };
|
|
@@ -3,11 +3,19 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
3
3
|
// src/controllers/input_mask_controller.ts
|
|
4
4
|
|
|
5
5
|
// src/utils/composition_tracker.ts
|
|
6
|
+
var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
7
|
+
"insertCompositionText",
|
|
8
|
+
"insertFromComposition",
|
|
9
|
+
"deleteCompositionText",
|
|
10
|
+
"deleteByComposition"
|
|
11
|
+
]);
|
|
6
12
|
var CompositionTracker = class {
|
|
7
13
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
8
14
|
#activeTargets = /* @__PURE__ */ new Set();
|
|
9
15
|
#onStart;
|
|
10
16
|
#onEnd;
|
|
17
|
+
/** The field whose confirming `input` is still owed, while the window is open. */
|
|
18
|
+
#confirmedTarget = null;
|
|
11
19
|
constructor(options = {}) {
|
|
12
20
|
this.#onStart = options.onStart;
|
|
13
21
|
this.#onEnd = options.onEnd;
|
|
@@ -17,6 +25,7 @@ var CompositionTracker = class {
|
|
|
17
25
|
if (this.#observedTargets.has(target)) return;
|
|
18
26
|
target.addEventListener("compositionstart", this.#handleStart);
|
|
19
27
|
target.addEventListener("compositionend", this.#handleEnd);
|
|
28
|
+
target.addEventListener("keydown", this.#handleKeydown);
|
|
20
29
|
this.#observedTargets.add(target);
|
|
21
30
|
}
|
|
22
31
|
/** Stops tracking one target and clears any active composition it owned. */
|
|
@@ -24,29 +33,52 @@ var CompositionTracker = class {
|
|
|
24
33
|
if (!this.#observedTargets.delete(target)) return;
|
|
25
34
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
26
35
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
36
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
27
37
|
this.#activeTargets.delete(target);
|
|
38
|
+
if (this.#confirmedTarget === target) this.#confirmedTarget = null;
|
|
28
39
|
}
|
|
29
40
|
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
30
41
|
disconnect() {
|
|
31
42
|
for (const target of this.#observedTargets) {
|
|
32
43
|
target.removeEventListener("compositionstart", this.#handleStart);
|
|
33
44
|
target.removeEventListener("compositionend", this.#handleEnd);
|
|
45
|
+
target.removeEventListener("keydown", this.#handleKeydown);
|
|
34
46
|
}
|
|
35
47
|
this.#observedTargets.clear();
|
|
36
48
|
this.#activeTargets.clear();
|
|
49
|
+
this.#confirmedTarget = null;
|
|
37
50
|
}
|
|
38
51
|
/** True when lifecycle tracking or the current event reports composition. */
|
|
39
52
|
isComposing(event) {
|
|
40
53
|
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
41
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether `event` is the `input` echoing the composition just confirmed.
|
|
57
|
+
*
|
|
58
|
+
* Asking closes the window either way, so one confirmation is folded at most
|
|
59
|
+
* once and a consumer asks once per `input`.
|
|
60
|
+
*/
|
|
61
|
+
consumesConfirmedInput(event) {
|
|
62
|
+
const confirmed = this.#confirmedTarget;
|
|
63
|
+
this.#confirmedTarget = null;
|
|
64
|
+
if (confirmed === null || confirmed !== event.target) return false;
|
|
65
|
+
const inputType = event.inputType;
|
|
66
|
+
return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
|
|
67
|
+
}
|
|
42
68
|
#handleStart = (event) => {
|
|
69
|
+
this.#confirmedTarget = null;
|
|
43
70
|
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
44
71
|
this.#onStart?.(event);
|
|
45
72
|
};
|
|
46
73
|
#handleEnd = (event) => {
|
|
47
74
|
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
75
|
+
this.#confirmedTarget = event.target;
|
|
48
76
|
this.#onEnd?.(event);
|
|
49
77
|
};
|
|
78
|
+
/** A key on an observed field opens an edit of its own, so no echo is owed. */
|
|
79
|
+
#handleKeydown = () => {
|
|
80
|
+
this.#confirmedTarget = null;
|
|
81
|
+
};
|
|
50
82
|
};
|
|
51
83
|
|
|
52
84
|
// src/utils/declared_value.ts
|
|
@@ -99,7 +131,7 @@ var DEFAULT_TOKENS = {
|
|
|
99
131
|
a: "[A-Za-z]",
|
|
100
132
|
"*": "[A-Za-z0-9]"
|
|
101
133
|
};
|
|
102
|
-
var UNMASK_ATTR = "
|
|
134
|
+
var UNMASK_ATTR = "unmask";
|
|
103
135
|
function acceptedForm(regex, char) {
|
|
104
136
|
if (regex.test(char)) return char;
|
|
105
137
|
const half = halfWidthChar(char);
|
|
@@ -176,6 +208,10 @@ function tokenCharOffset(tokenFlags, n) {
|
|
|
176
208
|
return -1;
|
|
177
209
|
}
|
|
178
210
|
var InputMaskController = class extends Controller {
|
|
211
|
+
/** The attribute above, in the namespace this controller is registered under. */
|
|
212
|
+
get #unmaskAttribute() {
|
|
213
|
+
return `data-${this.identifier}-${UNMASK_ATTR}`;
|
|
214
|
+
}
|
|
179
215
|
static values = {
|
|
180
216
|
pattern: { type: String, default: "" },
|
|
181
217
|
tokens: { type: String, default: "" },
|
|
@@ -188,8 +224,23 @@ var InputMaskController = class extends Controller {
|
|
|
188
224
|
#started = false;
|
|
189
225
|
/** Validated token map; the hot path never parses the `tokens` declaration. */
|
|
190
226
|
#tokens = compileTokens("");
|
|
191
|
-
/**
|
|
192
|
-
#
|
|
227
|
+
/** The field's text when the running composition began. */
|
|
228
|
+
#textBeforeComposition = "";
|
|
229
|
+
/**
|
|
230
|
+
* Holds mid-composition input so the IME's uncommitted text is never rewritten,
|
|
231
|
+
* and formats the confirmed text once when the composition ends. A composition
|
|
232
|
+
* that ends on the text it began with committed nothing, so a re-format then is
|
|
233
|
+
* the page's: the mask changed while it ran.
|
|
234
|
+
*/
|
|
235
|
+
#composition = new CompositionTracker({
|
|
236
|
+
onStart: () => {
|
|
237
|
+
this.#textBeforeComposition = this.element.value;
|
|
238
|
+
},
|
|
239
|
+
onEnd: () => {
|
|
240
|
+
const committedNothing = this.element.value === this.#textBeforeComposition;
|
|
241
|
+
this.#reformat(committedNothing ? "reconcile" : "edit");
|
|
242
|
+
}
|
|
243
|
+
});
|
|
193
244
|
/** Re-parses the declaration and re-formats under the tokens it now selects. */
|
|
194
245
|
tokensValueChanged() {
|
|
195
246
|
this.#tokens = compileTokens(this.tokensValue);
|
|
@@ -225,8 +276,11 @@ var InputMaskController = class extends Controller {
|
|
|
225
276
|
/**
|
|
226
277
|
* Core reformat: mask the current value, restore the caret, sync the sink and
|
|
227
278
|
* the state hooks, and report a moved value under the event `cause` selects.
|
|
279
|
+
*
|
|
280
|
+
* @stimeoRenderRoot
|
|
228
281
|
*/
|
|
229
282
|
#reformat(cause, deletion = null) {
|
|
283
|
+
if (this.#composition.isComposing()) return;
|
|
230
284
|
const input = this.element;
|
|
231
285
|
const raw = input.value;
|
|
232
286
|
if (this.patternValue === "") {
|
|
@@ -311,8 +365,8 @@ var InputMaskController = class extends Controller {
|
|
|
311
365
|
* `id` (`data-stimeo--input-mask-unmask="zip"`).
|
|
312
366
|
* 2. **Nearest container** — otherwise, walking up from the input, the first
|
|
313
367
|
* *value-less* sink in the closest ancestor. Wrapped input+sink pairs each
|
|
314
|
-
* find their own sink, and
|
|
315
|
-
*
|
|
368
|
+
* find their own sink, and a single form-level sink is found the same
|
|
369
|
+
* way. A sink claimed by another input's id is never matched here.
|
|
316
370
|
*
|
|
317
371
|
* Both steps only ever consider the associated form's own controls, so a sink
|
|
318
372
|
* belonging to a different form (or to none) is never written, while one the
|
|
@@ -324,10 +378,10 @@ var InputMaskController = class extends Controller {
|
|
|
324
378
|
const candidates = this.#sinkCandidates();
|
|
325
379
|
const id = this.element.id;
|
|
326
380
|
if (id.length > 0) {
|
|
327
|
-
const paired = candidates.find((sink) => sink.getAttribute(
|
|
381
|
+
const paired = candidates.find((sink) => sink.getAttribute(this.#unmaskAttribute) === id);
|
|
328
382
|
if (paired) return paired;
|
|
329
383
|
}
|
|
330
|
-
const free = candidates.filter((sink) => sink.getAttribute(
|
|
384
|
+
const free = candidates.filter((sink) => sink.getAttribute(this.#unmaskAttribute) === "");
|
|
331
385
|
for (let node = this.element.parentElement; node !== null; node = node.parentElement) {
|
|
332
386
|
const ancestor = node;
|
|
333
387
|
const sink = free.find((candidate) => ancestor.contains(candidate));
|
|
@@ -344,10 +398,12 @@ var InputMaskController = class extends Controller {
|
|
|
344
398
|
#sinkCandidates() {
|
|
345
399
|
const form = this.element.form;
|
|
346
400
|
if (form === null) {
|
|
347
|
-
return Array.from(
|
|
401
|
+
return Array.from(
|
|
402
|
+
document.querySelectorAll(`input[${this.#unmaskAttribute}]`)
|
|
403
|
+
);
|
|
348
404
|
}
|
|
349
405
|
return Array.from(form.elements).filter(
|
|
350
|
-
(element) => element instanceof HTMLInputElement && element.hasAttribute(
|
|
406
|
+
(element) => element instanceof HTMLInputElement && element.hasAttribute(this.#unmaskAttribute)
|
|
351
407
|
);
|
|
352
408
|
}
|
|
353
409
|
/** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
|
|
@@ -204,6 +204,9 @@ var IntersectionController = class extends Controller {
|
|
|
204
204
|
* so the observer's initial callback fires `enter` for an element that starts
|
|
205
205
|
* visible but stays silent after a cache restore that already recorded it.
|
|
206
206
|
* An initial not-visible state is established silently (no `exit`).
|
|
207
|
+
*
|
|
208
|
+
* @stimeoRuntimeOnly `once` decides whether this enter spends the watcher's one shot; the hook it
|
|
209
|
+
* writes follows the entry.
|
|
207
210
|
*/
|
|
208
211
|
#syncIntersecting(intersecting, ratio, entry) {
|
|
209
212
|
const previous = this.element.getAttribute("data-intersecting");
|
|
@@ -192,7 +192,12 @@ var LazyFrameController = class extends Controller {
|
|
|
192
192
|
if (this.onceValue || !returning) return;
|
|
193
193
|
this.#reload();
|
|
194
194
|
}
|
|
195
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Starts the load by writing the held URL to `src`.
|
|
197
|
+
*
|
|
198
|
+
* @stimeoRuntimeOnly `url` is the address this one load fetches and `once` decides whether it
|
|
199
|
+
* stops the watcher.
|
|
200
|
+
*/
|
|
196
201
|
#load() {
|
|
197
202
|
if (!this.urlValue) return;
|
|
198
203
|
this.#loaded = true;
|
|
@@ -202,7 +207,11 @@ var LazyFrameController = class extends Controller {
|
|
|
202
207
|
this.element.removeEventListener("focusin", this.#onFocus);
|
|
203
208
|
if (this.onceValue) this.#watcher.stop();
|
|
204
209
|
}
|
|
205
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* Re-entry while `once` is off: fetch the held URL again.
|
|
212
|
+
*
|
|
213
|
+
* @stimeoRuntimeOnly `url` is the address this one re-entry reloads.
|
|
214
|
+
*/
|
|
206
215
|
#reload() {
|
|
207
216
|
const src = this.element.getAttribute("src");
|
|
208
217
|
if (this.urlValue && this.urlValue !== src) {
|