stimeo-ui 0.2.1 → 0.4.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 +160 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +92 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +226 -22
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +77 -3
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +102 -8
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +408 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/progress_controller.js +116 -9
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +100 -9
- data/dist/controllers/rating_controller.js +69 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +49 -8
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +61 -10
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +4507 -907
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -2,6 +2,71 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/skeleton_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/min_duration_floor.ts
|
|
23
|
+
var MinDurationFloor = class {
|
|
24
|
+
#timers;
|
|
25
|
+
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
26
|
+
#timerId = null;
|
|
27
|
+
/** Epoch ms the floor is measured from. */
|
|
28
|
+
#since = 0;
|
|
29
|
+
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
30
|
+
constructor(timers) {
|
|
31
|
+
this.#timers = timers;
|
|
32
|
+
}
|
|
33
|
+
/** Starts the floor: call when the state being held becomes visible. */
|
|
34
|
+
begin() {
|
|
35
|
+
this.#since = Date.now();
|
|
36
|
+
}
|
|
37
|
+
/** True while a finish is held back waiting for the floor to elapse. */
|
|
38
|
+
get pending() {
|
|
39
|
+
return this.#timerId !== null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
43
|
+
*
|
|
44
|
+
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
45
|
+
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
46
|
+
* end a state that has since restarted. Controllers that want the first signal
|
|
47
|
+
* to win guard on {@link pending} before calling.
|
|
48
|
+
*/
|
|
49
|
+
schedule(minDuration, finish) {
|
|
50
|
+
this.cancel();
|
|
51
|
+
const remaining = minDuration - (Date.now() - this.#since);
|
|
52
|
+
if (remaining > 0) {
|
|
53
|
+
this.#timerId = this.#timers.set(() => {
|
|
54
|
+
this.#timerId = null;
|
|
55
|
+
finish();
|
|
56
|
+
}, remaining);
|
|
57
|
+
} else {
|
|
58
|
+
finish();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
62
|
+
cancel() {
|
|
63
|
+
if (this.#timerId !== null) {
|
|
64
|
+
this.#timers.clear(this.#timerId);
|
|
65
|
+
this.#timerId = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
5
70
|
// src/utils/safe_timeout.ts
|
|
6
71
|
var TimerRegistry = class {
|
|
7
72
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,15 +124,13 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
124
|
var SkeletonController = class extends Controller {
|
|
60
125
|
static targets = ["placeholder", "content"];
|
|
61
126
|
static values = {
|
|
127
|
+
announceReadyText: { type: String, default: "" },
|
|
62
128
|
minDuration: { type: Number, default: 0 }
|
|
63
129
|
};
|
|
64
130
|
static actions = ["ready", "reset"];
|
|
65
131
|
static events = ["ready"];
|
|
66
132
|
#timers = new SafeTimeout();
|
|
67
|
-
|
|
68
|
-
#revealTimerId = null;
|
|
69
|
-
/** Epoch ms when the loading state began, used to enforce `minDuration`. */
|
|
70
|
-
#loadingSince = 0;
|
|
133
|
+
#floor = new MinDurationFloor(this.#timers);
|
|
71
134
|
connect() {
|
|
72
135
|
if (this.#state !== "ready") {
|
|
73
136
|
this.#enterLoading();
|
|
@@ -75,32 +138,21 @@ var SkeletonController = class extends Controller {
|
|
|
75
138
|
}
|
|
76
139
|
disconnect() {
|
|
77
140
|
this.#timers.clearAll();
|
|
78
|
-
this.#
|
|
141
|
+
this.#floor.cancel();
|
|
79
142
|
}
|
|
80
143
|
/** Swaps to the real content. Honors `minDuration` to prevent a flash. */
|
|
81
144
|
ready() {
|
|
82
|
-
if (this.#state === "ready" || this.#
|
|
83
|
-
|
|
84
|
-
if (remaining > 0) {
|
|
85
|
-
this.#revealTimerId = this.#timers.set(() => {
|
|
86
|
-
this.#revealTimerId = null;
|
|
87
|
-
this.#reveal();
|
|
88
|
-
}, remaining);
|
|
89
|
-
} else {
|
|
90
|
-
this.#reveal();
|
|
91
|
-
}
|
|
145
|
+
if (this.#state === "ready" || this.#floor.pending) return;
|
|
146
|
+
this.#floor.schedule(this.minDurationValue, () => this.#reveal());
|
|
92
147
|
}
|
|
93
148
|
/** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
|
|
94
149
|
reset() {
|
|
95
|
-
|
|
96
|
-
this.#timers.clear(this.#revealTimerId);
|
|
97
|
-
this.#revealTimerId = null;
|
|
98
|
-
}
|
|
150
|
+
this.#floor.cancel();
|
|
99
151
|
this.#enterLoading();
|
|
100
152
|
}
|
|
101
153
|
/** Shows the placeholder, hides content, and marks the region busy. */
|
|
102
154
|
#enterLoading() {
|
|
103
|
-
this.#
|
|
155
|
+
this.#floor.begin();
|
|
104
156
|
if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;
|
|
105
157
|
if (this.hasContentTarget) this.contentTarget.hidden = true;
|
|
106
158
|
this.element.setAttribute("aria-busy", "true");
|
|
@@ -113,6 +165,7 @@ var SkeletonController = class extends Controller {
|
|
|
113
165
|
this.element.setAttribute("aria-busy", "false");
|
|
114
166
|
this.element.setAttribute("data-state", "ready");
|
|
115
167
|
this.dispatch("ready", { detail: {} });
|
|
168
|
+
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
116
169
|
}
|
|
117
170
|
/** Current lifecycle phase as reflected on `data-state`. */
|
|
118
171
|
get #state() {
|
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/slider_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowKey(key, element) {
|
|
12
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
13
|
+
if (!isRtl(element)) return key;
|
|
14
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
15
|
+
}
|
|
16
|
+
function isReservedArrowChord(event, allow = []) {
|
|
17
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
18
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/utils/range.ts
|
|
22
|
+
function rangeFraction(value, min, max) {
|
|
23
|
+
const span = max - min;
|
|
24
|
+
if (!(span > 0)) return 0;
|
|
25
|
+
const clamped = Math.min(max, Math.max(min, value));
|
|
26
|
+
let fraction;
|
|
27
|
+
if (Number.isFinite(span)) {
|
|
28
|
+
fraction = (clamped - min) / span;
|
|
29
|
+
} else {
|
|
30
|
+
const scale = Math.max(Math.abs(min), Math.abs(max));
|
|
31
|
+
fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
|
|
32
|
+
}
|
|
33
|
+
if (!Number.isFinite(fraction)) return 0;
|
|
34
|
+
return Math.min(1, Math.max(0, fraction));
|
|
35
|
+
}
|
|
36
|
+
|
|
3
37
|
// src/controllers/slider_controller.ts
|
|
4
38
|
var FRACTION_PROPERTY = "--stimeo--slider-fraction";
|
|
5
39
|
var SliderController = class extends Controller {
|
|
@@ -8,12 +42,17 @@ var SliderController = class extends Controller {
|
|
|
8
42
|
min: { type: Number, default: 0 },
|
|
9
43
|
max: { type: Number, default: 100 },
|
|
10
44
|
step: { type: Number, default: 1 },
|
|
11
|
-
value: { type: Number, default: 0 }
|
|
45
|
+
value: { type: Number, default: 0 },
|
|
46
|
+
logicalTrack: { type: Boolean, default: false }
|
|
12
47
|
};
|
|
13
48
|
static actions = ["onKeydown", "onPointerDown"];
|
|
14
49
|
static events = ["change"];
|
|
15
50
|
/** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
|
|
16
51
|
#dragAbort = null;
|
|
52
|
+
/** Whether the consumer declared a mirroring track and the direction mirrors it. */
|
|
53
|
+
get #mirrored() {
|
|
54
|
+
return this.logicalTrackValue && isRtl(this.element);
|
|
55
|
+
}
|
|
17
56
|
/** Clamps the initial value and renders the starting position. */
|
|
18
57
|
connect() {
|
|
19
58
|
this.#setValue(this.valueValue, { silent: true });
|
|
@@ -25,9 +64,10 @@ var SliderController = class extends Controller {
|
|
|
25
64
|
}
|
|
26
65
|
/** Handles keyboard stepping per the APG slider model. */
|
|
27
66
|
onKeydown(event) {
|
|
67
|
+
if (isReservedArrowChord(event)) return;
|
|
28
68
|
const big = this.stepValue * 10;
|
|
29
69
|
let next = null;
|
|
30
|
-
switch (event.key) {
|
|
70
|
+
switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
|
|
31
71
|
case "ArrowRight":
|
|
32
72
|
case "ArrowUp":
|
|
33
73
|
next = this.valueValue + this.stepValue;
|
|
@@ -58,12 +98,13 @@ var SliderController = class extends Controller {
|
|
|
58
98
|
onPointerDown(event) {
|
|
59
99
|
if (!this.hasTrackTarget) return;
|
|
60
100
|
event.preventDefault();
|
|
61
|
-
this.#
|
|
101
|
+
const mirrored = this.#mirrored;
|
|
102
|
+
this.#updateFromClientX(event.clientX, mirrored);
|
|
62
103
|
if (this.hasThumbTarget) this.thumbTarget.focus();
|
|
63
104
|
this.#dragAbort?.abort();
|
|
64
105
|
const abort = new AbortController();
|
|
65
106
|
this.#dragAbort = abort;
|
|
66
|
-
const onMove = (move) => this.#updateFromClientX(move.clientX);
|
|
107
|
+
const onMove = (move) => this.#updateFromClientX(move.clientX, mirrored);
|
|
67
108
|
const onUp = () => {
|
|
68
109
|
abort.abort();
|
|
69
110
|
this.#dragAbort = null;
|
|
@@ -73,10 +114,11 @@ var SliderController = class extends Controller {
|
|
|
73
114
|
document.addEventListener("pointercancel", onUp, { signal: abort.signal });
|
|
74
115
|
}
|
|
75
116
|
/** Maps a pointer X coordinate to a value using the track's geometry. */
|
|
76
|
-
#updateFromClientX(clientX) {
|
|
117
|
+
#updateFromClientX(clientX, mirrored) {
|
|
77
118
|
const rect = this.trackTarget.getBoundingClientRect();
|
|
78
119
|
if (rect.width === 0) return;
|
|
79
|
-
const
|
|
120
|
+
const offset = (clientX - rect.left) / rect.width;
|
|
121
|
+
const fraction = mirrored ? 1 - offset : offset;
|
|
80
122
|
this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));
|
|
81
123
|
}
|
|
82
124
|
/**
|
|
@@ -97,8 +139,7 @@ var SliderController = class extends Controller {
|
|
|
97
139
|
this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
|
|
98
140
|
this.thumbTarget.setAttribute("aria-valuenow", String(value));
|
|
99
141
|
}
|
|
100
|
-
const
|
|
101
|
-
const fraction = span > 0 ? (value - this.minValue) / span : 0;
|
|
142
|
+
const fraction = rangeFraction(value, this.minValue, this.maxValue);
|
|
102
143
|
this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
|
|
103
144
|
if (changed && !silent) this.dispatch("change", { detail: { value } });
|
|
104
145
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/sortable_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
// src/controllers/sortable_controller.ts
|
|
4
11
|
var SortableController = class extends Controller {
|
|
5
12
|
static targets = ["list", "item", "status"];
|
|
@@ -67,6 +74,12 @@ var SortableController = class extends Controller {
|
|
|
67
74
|
* Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
|
|
68
75
|
* the difference from the last consumed value is one arrow press — its sign is
|
|
69
76
|
* the direction. Cross-axis arrows never change the primary delta (no move).
|
|
77
|
+
*
|
|
78
|
+
* The sign is physical (`ArrowRight` is always `+dx`), so a right-to-left row
|
|
79
|
+
* has to invert it: there, moving the item rightward means moving it *earlier*
|
|
80
|
+
* in the DOM. Skipping this would also split the two halves of one keypress —
|
|
81
|
+
* `roving` already moves focus logically, so the same arrow would send the
|
|
82
|
+
* focus and the grabbed item opposite ways.
|
|
70
83
|
*/
|
|
71
84
|
#stepFromKeyboard(session, detail) {
|
|
72
85
|
const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
|
|
@@ -75,7 +88,8 @@ var SortableController = class extends Controller {
|
|
|
75
88
|
if (delta === 0) return;
|
|
76
89
|
const items = this.#items();
|
|
77
90
|
const index = items.indexOf(session.item);
|
|
78
|
-
const
|
|
91
|
+
const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
|
|
92
|
+
const next = Math.max(0, Math.min(index + step, items.length - 1));
|
|
79
93
|
if (next === index) return;
|
|
80
94
|
this.#moveTo(session.item, next);
|
|
81
95
|
this.#announce("moved", session.item);
|
|
@@ -91,11 +105,13 @@ var SortableController = class extends Controller {
|
|
|
91
105
|
if (others.length === 0) return;
|
|
92
106
|
let laidOut = false;
|
|
93
107
|
let target = 0;
|
|
108
|
+
const reversed = this.#isReversed;
|
|
94
109
|
for (const other of others) {
|
|
95
110
|
const rect = other.getBoundingClientRect();
|
|
96
111
|
if (rect.width > 0 || rect.height > 0) laidOut = true;
|
|
97
112
|
const midpoint = this.#isVertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
|
|
98
|
-
|
|
113
|
+
const precedes = reversed ? pointer < midpoint : pointer > midpoint;
|
|
114
|
+
if (precedes) target += 1;
|
|
99
115
|
}
|
|
100
116
|
if (!laidOut) return;
|
|
101
117
|
const current = this.#items().indexOf(session.item);
|
|
@@ -118,7 +134,7 @@ var SortableController = class extends Controller {
|
|
|
118
134
|
* Mirrors a step into the `status` live region. Copy is localizable through
|
|
119
135
|
* `data-grabbed` / `data-moved` / `data-dropped` / `data-canceled` templates on
|
|
120
136
|
* the status element (`%{name}` / `%{position}` / `%{total}` placeholders);
|
|
121
|
-
* terse English is the fallback
|
|
137
|
+
* terse English is the fallback.
|
|
122
138
|
*/
|
|
123
139
|
#announce(key, item) {
|
|
124
140
|
if (!this.hasStatusTarget) return;
|
|
@@ -160,6 +176,21 @@ var SortableController = class extends Controller {
|
|
|
160
176
|
get #isVertical() {
|
|
161
177
|
return this.orientationValue !== "horizontal";
|
|
162
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether DOM order runs opposite to the primary coordinate — true only for a
|
|
181
|
+
* horizontal row under `dir="rtl"`, where the first item sits at the largest
|
|
182
|
+
* `x`. Both drag paths reach this controller in physical terms (`pointer-drag`
|
|
183
|
+
* documents its deltas as physical and hands RTL to its consumer, which is
|
|
184
|
+
* this controller), so both have to be mapped back onto DOM order here.
|
|
185
|
+
*
|
|
186
|
+
* Read from the list, the element that lays the items out — never from the
|
|
187
|
+
* dragged item, which may carry its own `dir`. The list inherits the computed
|
|
188
|
+
* direction, so authoring `dir` on the root works too. A vertical list is
|
|
189
|
+
* unaffected: writing direction does not mirror the block axis.
|
|
190
|
+
*/
|
|
191
|
+
get #isReversed() {
|
|
192
|
+
return !this.#isVertical && isRtl(this.#list);
|
|
193
|
+
}
|
|
163
194
|
};
|
|
164
195
|
|
|
165
196
|
export { SortableController };
|
|
@@ -2,6 +2,145 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/spinner_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/before_cache_reset.ts
|
|
23
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
24
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
25
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
26
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
27
|
+
static #onBeforeCache = () => {
|
|
28
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
29
|
+
};
|
|
30
|
+
#rewind;
|
|
31
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
32
|
+
constructor(rewind) {
|
|
33
|
+
this.#rewind = rewind;
|
|
34
|
+
}
|
|
35
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
36
|
+
activate() {
|
|
37
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
38
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
39
|
+
if (first) {
|
|
40
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
44
|
+
deactivate() {
|
|
45
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
46
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
47
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/utils/detach_gate.ts
|
|
52
|
+
var DetachGate = class _DetachGate {
|
|
53
|
+
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
54
|
+
#pending = false;
|
|
55
|
+
/**
|
|
56
|
+
* True when the disconnect is definitely a real detach — the element left
|
|
57
|
+
* the document, or `data-controller` no longer lists the identifier. False
|
|
58
|
+
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
59
|
+
*/
|
|
60
|
+
static isDetached(host) {
|
|
61
|
+
if (!host.element.isConnected) return true;
|
|
62
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
63
|
+
return !tokens.includes(host.identifier);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
67
|
+
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
68
|
+
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
69
|
+
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
70
|
+
* element within the same mutation batch, before the checkpoint drains.
|
|
71
|
+
*/
|
|
72
|
+
disconnected(host, teardown) {
|
|
73
|
+
if (_DetachGate.isDetached(host)) {
|
|
74
|
+
this.#pending = false;
|
|
75
|
+
teardown();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.#pending = true;
|
|
79
|
+
queueMicrotask(() => {
|
|
80
|
+
if (!this.#pending) return;
|
|
81
|
+
this.#pending = false;
|
|
82
|
+
teardown();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
87
|
+
* an in-page move) and from the head of any teardown path not routed through
|
|
88
|
+
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
89
|
+
* never run the teardown a second time.
|
|
90
|
+
*/
|
|
91
|
+
cancel() {
|
|
92
|
+
this.#pending = false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/utils/min_duration_floor.ts
|
|
97
|
+
var MinDurationFloor = class {
|
|
98
|
+
#timers;
|
|
99
|
+
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
100
|
+
#timerId = null;
|
|
101
|
+
/** Epoch ms the floor is measured from. */
|
|
102
|
+
#since = 0;
|
|
103
|
+
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
104
|
+
constructor(timers) {
|
|
105
|
+
this.#timers = timers;
|
|
106
|
+
}
|
|
107
|
+
/** Starts the floor: call when the state being held becomes visible. */
|
|
108
|
+
begin() {
|
|
109
|
+
this.#since = Date.now();
|
|
110
|
+
}
|
|
111
|
+
/** True while a finish is held back waiting for the floor to elapse. */
|
|
112
|
+
get pending() {
|
|
113
|
+
return this.#timerId !== null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
117
|
+
*
|
|
118
|
+
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
119
|
+
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
120
|
+
* end a state that has since restarted. Controllers that want the first signal
|
|
121
|
+
* to win guard on {@link pending} before calling.
|
|
122
|
+
*/
|
|
123
|
+
schedule(minDuration, finish) {
|
|
124
|
+
this.cancel();
|
|
125
|
+
const remaining = minDuration - (Date.now() - this.#since);
|
|
126
|
+
if (remaining > 0) {
|
|
127
|
+
this.#timerId = this.#timers.set(() => {
|
|
128
|
+
this.#timerId = null;
|
|
129
|
+
finish();
|
|
130
|
+
}, remaining);
|
|
131
|
+
} else {
|
|
132
|
+
finish();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
136
|
+
cancel() {
|
|
137
|
+
if (this.#timerId !== null) {
|
|
138
|
+
this.#timers.clear(this.#timerId);
|
|
139
|
+
this.#timerId = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
5
144
|
// src/utils/safe_timeout.ts
|
|
6
145
|
var TimerRegistry = class {
|
|
7
146
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,38 +198,61 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
198
|
var SpinnerController = class extends Controller {
|
|
60
199
|
static targets = ["indicator", "region", "message"];
|
|
61
200
|
static values = {
|
|
201
|
+
announceText: { type: String, default: "" },
|
|
202
|
+
announceReadyText: { type: String, default: "" },
|
|
62
203
|
delay: { type: Number, default: 0 },
|
|
63
|
-
minDuration: { type: Number, default: 0 }
|
|
204
|
+
minDuration: { type: Number, default: 0 },
|
|
205
|
+
timeout: { type: Number, default: 0 }
|
|
64
206
|
};
|
|
65
207
|
static actions = ["start", "stop"];
|
|
66
|
-
static events = ["hide", "show"];
|
|
208
|
+
static events = ["hide", "show", "timeout"];
|
|
67
209
|
#timers = new SafeTimeout();
|
|
210
|
+
#floor = new MinDurationFloor(this.#timers);
|
|
211
|
+
#gate = new DetachGate();
|
|
212
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
68
213
|
/** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
|
|
69
214
|
#delayTimerId = null;
|
|
70
|
-
/** Pending
|
|
71
|
-
#
|
|
72
|
-
/** Epoch ms when the spinner became visible, used to enforce `minDuration`. */
|
|
73
|
-
#shownAt = 0;
|
|
215
|
+
/** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
|
|
216
|
+
#timeoutTimerId = null;
|
|
74
217
|
connect() {
|
|
218
|
+
this.#gate.cancel();
|
|
219
|
+
this.#beforeCache.activate();
|
|
220
|
+
if (this.#state === "pending" && this.#delayTimerId === null) {
|
|
221
|
+
this.#setBusy(false);
|
|
222
|
+
this.element.setAttribute("data-state", "idle");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
75
225
|
if (!this.element.hasAttribute("data-state")) {
|
|
76
226
|
this.element.setAttribute("data-state", "idle");
|
|
77
227
|
}
|
|
78
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Re-applies the current phase to an indicator that arrived after `connect()`.
|
|
231
|
+
*
|
|
232
|
+
* A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
|
|
233
|
+
* carries the markup contract's `hidden`. Without this the spinner would vanish
|
|
234
|
+
* while `data-state` still says `loading`, and nothing but the next cycle would
|
|
235
|
+
* bring it back.
|
|
236
|
+
*/
|
|
237
|
+
indicatorTargetConnected(target) {
|
|
238
|
+
target.hidden = this.#state !== "loading";
|
|
239
|
+
}
|
|
79
240
|
disconnect() {
|
|
80
|
-
this.#
|
|
81
|
-
this.#
|
|
82
|
-
this.#hideTimerId = null;
|
|
241
|
+
this.#beforeCache.deactivate();
|
|
242
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
83
243
|
}
|
|
84
244
|
/** Begins loading. Honors `delay` before the spinner actually appears. */
|
|
85
245
|
start() {
|
|
86
246
|
if (this.#state === "loading") {
|
|
87
247
|
this.#setBusy(true);
|
|
88
|
-
this.#
|
|
248
|
+
this.#floor.cancel();
|
|
249
|
+
this.#armTimeout();
|
|
89
250
|
return;
|
|
90
251
|
}
|
|
91
252
|
if (this.#state !== "idle") return;
|
|
92
253
|
this.#setBusy(true);
|
|
93
|
-
this.#
|
|
254
|
+
this.#floor.cancel();
|
|
255
|
+
this.#armTimeout();
|
|
94
256
|
if (this.delayValue > 0) {
|
|
95
257
|
this.element.setAttribute("data-state", "pending");
|
|
96
258
|
this.#delayTimerId = this.#timers.set(() => {
|
|
@@ -104,6 +266,7 @@ var SpinnerController = class extends Controller {
|
|
|
104
266
|
/** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
|
|
105
267
|
stop() {
|
|
106
268
|
const state = this.#state;
|
|
269
|
+
this.#cancelTimeout();
|
|
107
270
|
if (state === "pending") {
|
|
108
271
|
this.#cancelDelay();
|
|
109
272
|
this.#setBusy(false);
|
|
@@ -112,28 +275,52 @@ var SpinnerController = class extends Controller {
|
|
|
112
275
|
}
|
|
113
276
|
if (state !== "loading") return;
|
|
114
277
|
this.#setBusy(false);
|
|
115
|
-
|
|
116
|
-
if (remaining > 0) {
|
|
117
|
-
this.#hideTimerId = this.#timers.set(() => {
|
|
118
|
-
this.#hideTimerId = null;
|
|
119
|
-
this.#hide();
|
|
120
|
-
}, remaining);
|
|
121
|
-
} else {
|
|
122
|
-
this.#hide();
|
|
123
|
-
}
|
|
278
|
+
this.#floor.schedule(this.minDurationValue, () => this.#hide());
|
|
124
279
|
}
|
|
125
280
|
/** Reveals the indicator, marks the moment shown, and announces via the live region. */
|
|
126
281
|
#show() {
|
|
127
|
-
this.#
|
|
282
|
+
this.#floor.begin();
|
|
283
|
+
this.#setBusy(true);
|
|
128
284
|
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
|
|
129
285
|
this.element.setAttribute("data-state", "loading");
|
|
130
286
|
this.dispatch("show", { detail: {} });
|
|
287
|
+
announce(fillTemplate(this.announceTextValue, {}));
|
|
131
288
|
}
|
|
132
289
|
/** Hides the indicator and returns to the idle state. */
|
|
133
290
|
#hide() {
|
|
134
291
|
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
|
|
135
292
|
this.element.setAttribute("data-state", "idle");
|
|
136
293
|
this.dispatch("hide", { detail: {} });
|
|
294
|
+
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Drops both timers on a real detach. The markup keeps whatever it last held: an
|
|
298
|
+
* element on its way out of the document has no reader left, and one whose
|
|
299
|
+
* `data-controller` dropped the identifier no longer resolves its own targets, so
|
|
300
|
+
* the rollback could only ever be partial. The snapshot is rewound where it is
|
|
301
|
+
* still whole, on `turbo:before-cache`.
|
|
302
|
+
*/
|
|
303
|
+
#teardown() {
|
|
304
|
+
this.#gate.cancel();
|
|
305
|
+
this.#timers.clearAll();
|
|
306
|
+
this.#delayTimerId = null;
|
|
307
|
+
this.#timeoutTimerId = null;
|
|
308
|
+
this.#floor.cancel();
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Returns the loading state to idle for the snapshot Turbo is about to take,
|
|
312
|
+
* so a page reached with the Back button is not restored mid-load with a
|
|
313
|
+
* spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
|
|
314
|
+
* and `aria-busy`. No `hide` is dispatched — the load was never observed to
|
|
315
|
+
* finish, and a snapshot rewind is not a lifecycle event the consumer can act
|
|
316
|
+
* on. The live page keeps its timers, so a navigation that never completes
|
|
317
|
+
* leaves the running cycle intact.
|
|
318
|
+
*/
|
|
319
|
+
#rewindForCache() {
|
|
320
|
+
this.#cancelTimeout();
|
|
321
|
+
this.#setBusy(false);
|
|
322
|
+
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
|
|
323
|
+
this.element.setAttribute("data-state", "idle");
|
|
137
324
|
}
|
|
138
325
|
/** Reflects busy state onto the controlled region (if present). */
|
|
139
326
|
#setBusy(busy) {
|
|
@@ -141,18 +328,32 @@ var SpinnerController = class extends Controller {
|
|
|
141
328
|
this.regionTarget.setAttribute("aria-busy", String(busy));
|
|
142
329
|
}
|
|
143
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Arms the safety net so a `stop` that never arrives cannot strand the spinner.
|
|
333
|
+
* Off by default: the consumer owns the async work, so only it knows whether a
|
|
334
|
+
* ceiling makes sense. Re-arming on a restart measures from the newest start.
|
|
335
|
+
*/
|
|
336
|
+
#armTimeout() {
|
|
337
|
+
this.#cancelTimeout();
|
|
338
|
+
if (this.timeoutValue <= 0) return;
|
|
339
|
+
this.#timeoutTimerId = this.#timers.set(() => {
|
|
340
|
+
this.#timeoutTimerId = null;
|
|
341
|
+
this.dispatch("timeout", { detail: {} });
|
|
342
|
+
this.stop();
|
|
343
|
+
}, this.timeoutValue);
|
|
344
|
+
}
|
|
345
|
+
#cancelTimeout() {
|
|
346
|
+
if (this.#timeoutTimerId !== null) {
|
|
347
|
+
this.#timers.clear(this.#timeoutTimerId);
|
|
348
|
+
this.#timeoutTimerId = null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
144
351
|
#cancelDelay() {
|
|
145
352
|
if (this.#delayTimerId !== null) {
|
|
146
353
|
this.#timers.clear(this.#delayTimerId);
|
|
147
354
|
this.#delayTimerId = null;
|
|
148
355
|
}
|
|
149
356
|
}
|
|
150
|
-
#cancelHide() {
|
|
151
|
-
if (this.#hideTimerId !== null) {
|
|
152
|
-
this.#timers.clear(this.#hideTimerId);
|
|
153
|
-
this.#hideTimerId = null;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
357
|
/** Current lifecycle phase as reflected on `data-state`. */
|
|
157
358
|
get #state() {
|
|
158
359
|
return this.element.getAttribute("data-state") ?? "idle";
|