stimeo-ui 0.3.0 → 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 +56 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/color_picker_controller.js +46 -0
- data/dist/controllers/command_palette_controller.js +32 -5
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/date_range_picker_controller.js +50 -0
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/local_time_controller.js +100 -6
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/overflow_menu_controller.js +33 -6
- data/dist/controllers/progress_controller.js +116 -9
- data/dist/controllers/range_slider_controller.js +68 -3
- data/dist/controllers/rating_controller.js +53 -0
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +17 -2
- 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 +60 -10
- data/dist/index.js +1056 -281
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,56 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/countdown_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/microtask_coalescer.ts
|
|
23
|
+
var MicrotaskCoalescer = class {
|
|
24
|
+
#run;
|
|
25
|
+
#queued = false;
|
|
26
|
+
#active = false;
|
|
27
|
+
#generation = 0;
|
|
28
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
29
|
+
constructor(run) {
|
|
30
|
+
this.#run = run;
|
|
31
|
+
}
|
|
32
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
33
|
+
activate() {
|
|
34
|
+
this.#active = true;
|
|
35
|
+
}
|
|
36
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
37
|
+
cancel() {
|
|
38
|
+
this.#active = false;
|
|
39
|
+
this.#queued = false;
|
|
40
|
+
this.#generation += 1;
|
|
41
|
+
}
|
|
42
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
43
|
+
schedule() {
|
|
44
|
+
if (!this.#active || this.#queued) return;
|
|
45
|
+
this.#queued = true;
|
|
46
|
+
const generation = this.#generation;
|
|
47
|
+
queueMicrotask(() => {
|
|
48
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
49
|
+
this.#queued = false;
|
|
50
|
+
this.#run();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
5
55
|
// src/utils/safe_timeout.ts
|
|
6
56
|
var TimerRegistry = class {
|
|
7
57
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -48,6 +98,7 @@ var SafeInterval = class extends TimerRegistry {
|
|
|
48
98
|
};
|
|
49
99
|
|
|
50
100
|
// src/controllers/countdown_controller.ts
|
|
101
|
+
var SECOND_MS = 1e3;
|
|
51
102
|
var CountdownController = class extends Controller {
|
|
52
103
|
static targets = ["days", "hours", "minutes", "seconds", "status"];
|
|
53
104
|
static values = {
|
|
@@ -55,34 +106,79 @@ var CountdownController = class extends Controller {
|
|
|
55
106
|
interval: { type: Number, default: 1e3 },
|
|
56
107
|
direction: { type: String, default: "down" },
|
|
57
108
|
autostart: { type: Boolean, default: true },
|
|
58
|
-
completeLabel: { type: String, default: "" }
|
|
109
|
+
completeLabel: { type: String, default: "" },
|
|
110
|
+
announceText: { type: String, default: "" }
|
|
59
111
|
};
|
|
60
112
|
static actions = ["pause", "reset", "resume", "start"];
|
|
61
113
|
static events = ["complete", "tick"];
|
|
62
114
|
#intervals = new SafeInterval();
|
|
63
115
|
#intervalId = null;
|
|
116
|
+
/** Collapses a morph that swaps several render inputs at once into one re-derive. */
|
|
117
|
+
#resync = new MicrotaskCoalescer(() => this.#resyncToValues());
|
|
64
118
|
/** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
|
|
65
119
|
#reference = 0;
|
|
66
120
|
/** Amount (ms) captured at pause, so resume can restore the same display. */
|
|
67
121
|
#pausedAmount = 0;
|
|
122
|
+
/**
|
|
123
|
+
* The amount the slots are currently showing, floored to the second they render.
|
|
124
|
+
* It lags {@link currentAmount} by up to one tick, and it — not the live reading —
|
|
125
|
+
* is what a pause has to preserve: storing the fraction behind the display instead
|
|
126
|
+
* makes the first tick after a resume step by two units.
|
|
127
|
+
*/
|
|
128
|
+
#renderedAmount = 0;
|
|
68
129
|
connect() {
|
|
130
|
+
this.#resync.activate();
|
|
69
131
|
this.#initReference();
|
|
70
|
-
this.#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
132
|
+
const amount = this.#currentAmount();
|
|
133
|
+
this.#render(amount);
|
|
134
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
135
|
+
const authored = this.element.getAttribute("data-state");
|
|
136
|
+
if (authored === null) {
|
|
137
|
+
if (this.autostartValue && this.#isValidDeadline) {
|
|
138
|
+
this.start();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
74
141
|
this.element.setAttribute("data-state", "paused");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (authored === "complete" && this.#isDown && amount <= 0) {
|
|
145
|
+
return;
|
|
75
146
|
}
|
|
147
|
+
const wasRunning = authored === "running";
|
|
148
|
+
this.element.setAttribute("data-state", "paused");
|
|
149
|
+
if (wasRunning) this.start();
|
|
76
150
|
}
|
|
77
151
|
disconnect() {
|
|
152
|
+
this.#resync.cancel();
|
|
78
153
|
this.#intervals.clearAll();
|
|
79
154
|
this.#intervalId = null;
|
|
80
155
|
}
|
|
156
|
+
/** Re-derives the display when a morph swaps the deadline in place. */
|
|
157
|
+
deadlineValueChanged() {
|
|
158
|
+
this.#resync.schedule();
|
|
159
|
+
}
|
|
160
|
+
/** Re-derives the display when a morph flips the counting direction in place. */
|
|
161
|
+
directionValueChanged() {
|
|
162
|
+
this.#resync.schedule();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Points the anchor at the current `deadline` / `direction` and repaints.
|
|
166
|
+
*
|
|
167
|
+
* Render only: it starts no interval and emits no event, so a morph cannot make a
|
|
168
|
+
* paused timer run or replay a milestone. A running one needs no restart either —
|
|
169
|
+
* every tick reads the anchor, so moving it is enough. While paused the stored
|
|
170
|
+
* amount follows the new reading, or resume would continue from the old deadline.
|
|
171
|
+
*/
|
|
172
|
+
#resyncToValues() {
|
|
173
|
+
this.#initReference();
|
|
174
|
+
this.#render(this.#currentAmount());
|
|
175
|
+
if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
|
|
176
|
+
}
|
|
81
177
|
/** Starts (or restarts after pause) ticking toward the deadline. */
|
|
82
178
|
start() {
|
|
83
179
|
if (this.#state === "running" || !this.#isValidDeadline) return;
|
|
84
180
|
if (this.#isDown && this.#currentAmount() <= 0) {
|
|
85
|
-
this.#complete();
|
|
181
|
+
if (this.#state !== "complete") this.#complete();
|
|
86
182
|
return;
|
|
87
183
|
}
|
|
88
184
|
this.#runInterval();
|
|
@@ -90,7 +186,7 @@ var CountdownController = class extends Controller {
|
|
|
90
186
|
/** Pauses ticking, preserving the currently displayed amount. */
|
|
91
187
|
pause() {
|
|
92
188
|
if (this.#state !== "running") return;
|
|
93
|
-
this.#pausedAmount = this.#
|
|
189
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
94
190
|
this.#teardownInterval();
|
|
95
191
|
this.element.setAttribute("data-state", "paused");
|
|
96
192
|
}
|
|
@@ -106,8 +202,8 @@ var CountdownController = class extends Controller {
|
|
|
106
202
|
* run state**: a running timer keeps counting down from the reset amount, while a
|
|
107
203
|
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
108
204
|
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
109
|
-
* not re-derived from the declarative `autostart` Value (which only
|
|
110
|
-
*
|
|
205
|
+
* not re-derived from the declarative `autostart` Value (which governs only markup
|
|
206
|
+
* that states no run state at all); re-deriving it would override a user's pause —
|
|
111
207
|
* the DOM, not a re-run of declarative config, is the source of truth.
|
|
112
208
|
*/
|
|
113
209
|
reset() {
|
|
@@ -116,13 +212,15 @@ var CountdownController = class extends Controller {
|
|
|
116
212
|
this.#initReference();
|
|
117
213
|
const amount = this.#currentAmount();
|
|
118
214
|
this.#render(amount);
|
|
119
|
-
if (this.hasStatusTarget
|
|
215
|
+
if (this.hasStatusTarget && this.statusTarget.textContent === this.completeLabelValue) {
|
|
216
|
+
this.statusTarget.textContent = "";
|
|
217
|
+
}
|
|
120
218
|
this.element.setAttribute("data-state", "paused");
|
|
121
219
|
if (wasRunning && this.#isValidDeadline) {
|
|
122
220
|
this.#pausedAmount = 0;
|
|
123
221
|
this.start();
|
|
124
222
|
} else {
|
|
125
|
-
this.#pausedAmount =
|
|
223
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
126
224
|
}
|
|
127
225
|
}
|
|
128
226
|
/** Schedules the repeating tick and marks the timer running. */
|
|
@@ -157,6 +255,7 @@ var CountdownController = class extends Controller {
|
|
|
157
255
|
this.statusTarget.textContent = this.completeLabelValue;
|
|
158
256
|
}
|
|
159
257
|
this.dispatch("complete", { detail: {} });
|
|
258
|
+
announce(fillTemplate(this.announceTextValue, {}));
|
|
160
259
|
}
|
|
161
260
|
/** Sets the time anchor from the `deadline` value. */
|
|
162
261
|
#initReference() {
|
|
@@ -171,7 +270,8 @@ var CountdownController = class extends Controller {
|
|
|
171
270
|
}
|
|
172
271
|
/** Writes the amount into the day/hour/minute/second slots. */
|
|
173
272
|
#render(amount) {
|
|
174
|
-
const totalSeconds = Math.floor(amount /
|
|
273
|
+
const totalSeconds = Math.floor(amount / SECOND_MS);
|
|
274
|
+
this.#renderedAmount = totalSeconds * SECOND_MS;
|
|
175
275
|
const days = Math.floor(totalSeconds / 86400);
|
|
176
276
|
const hours = Math.floor(totalSeconds % 86400 / 3600);
|
|
177
277
|
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
@@ -52,6 +52,39 @@ function toISOMonthString(date) {
|
|
|
52
52
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// src/utils/microtask_coalescer.ts
|
|
56
|
+
var MicrotaskCoalescer = class {
|
|
57
|
+
#run;
|
|
58
|
+
#queued = false;
|
|
59
|
+
#active = false;
|
|
60
|
+
#generation = 0;
|
|
61
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
62
|
+
constructor(run) {
|
|
63
|
+
this.#run = run;
|
|
64
|
+
}
|
|
65
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
66
|
+
activate() {
|
|
67
|
+
this.#active = true;
|
|
68
|
+
}
|
|
69
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
70
|
+
cancel() {
|
|
71
|
+
this.#active = false;
|
|
72
|
+
this.#queued = false;
|
|
73
|
+
this.#generation += 1;
|
|
74
|
+
}
|
|
75
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
76
|
+
schedule() {
|
|
77
|
+
if (!this.#active || this.#queued) return;
|
|
78
|
+
this.#queued = true;
|
|
79
|
+
const generation = this.#generation;
|
|
80
|
+
queueMicrotask(() => {
|
|
81
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
82
|
+
this.#queued = false;
|
|
83
|
+
this.#run();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
55
88
|
// src/utils/safe_timeout.ts
|
|
56
89
|
var TimerRegistry = class {
|
|
57
90
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -129,7 +162,15 @@ var DateRangePickerController = class extends Controller {
|
|
|
129
162
|
/** Deferred focus after an async month transition (cancelled on teardown). */
|
|
130
163
|
#focusTimer = new SafeTimeout();
|
|
131
164
|
/** Seeds the range from any pre-filled hidden fields and renders the grid. */
|
|
165
|
+
/**
|
|
166
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
167
|
+
* pass Stimulus delivers before `connect()`.
|
|
168
|
+
*/
|
|
169
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
170
|
+
this.#render();
|
|
171
|
+
});
|
|
132
172
|
connect() {
|
|
173
|
+
this.#repaint.activate();
|
|
133
174
|
this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
|
|
134
175
|
this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
|
|
135
176
|
const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
|
|
@@ -140,8 +181,17 @@ var DateRangePickerController = class extends Controller {
|
|
|
140
181
|
}
|
|
141
182
|
/** Cancels any pending deferred focus so it never fires on a detached element. */
|
|
142
183
|
disconnect() {
|
|
184
|
+
this.#repaint.cancel();
|
|
143
185
|
this.#focusTimer.clearAll();
|
|
144
186
|
}
|
|
187
|
+
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
188
|
+
minValueChanged() {
|
|
189
|
+
this.#repaint.schedule();
|
|
190
|
+
}
|
|
191
|
+
/** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
|
|
192
|
+
maxValueChanged() {
|
|
193
|
+
this.#repaint.schedule();
|
|
194
|
+
}
|
|
145
195
|
/** Navigates to the previous month. */
|
|
146
196
|
prev(event) {
|
|
147
197
|
event?.preventDefault();
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/dialog_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/drawer_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/empty_state_controller.ts
|
|
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
|
+
|
|
3
22
|
// src/controllers/empty_state_controller.ts
|
|
4
23
|
var EmptyStateController = class extends Controller {
|
|
5
24
|
static targets = ["list", "empty"];
|
|
6
25
|
static values = {
|
|
7
26
|
itemSelector: { type: String, default: "" },
|
|
8
|
-
|
|
27
|
+
announceText: { type: String, default: "" },
|
|
28
|
+
announceFilledText: { type: String, default: "" }
|
|
9
29
|
};
|
|
10
30
|
static events = ["change"];
|
|
11
31
|
#observer = null;
|
|
@@ -13,10 +33,6 @@ var EmptyStateController = class extends Controller {
|
|
|
13
33
|
#empty = null;
|
|
14
34
|
connect() {
|
|
15
35
|
if (!this.hasListTarget) return;
|
|
16
|
-
if (this.announceValue && this.hasEmptyTarget && !this.#isLiveRegion(this.emptyTarget)) {
|
|
17
|
-
this.emptyTarget.setAttribute("role", "status");
|
|
18
|
-
this.emptyTarget.setAttribute("aria-live", "polite");
|
|
19
|
-
}
|
|
20
36
|
if (typeof MutationObserver !== "undefined") {
|
|
21
37
|
this.#observer = new MutationObserver(() => this.#apply());
|
|
22
38
|
this.#observer.observe(this.listTarget, { childList: true });
|
|
@@ -42,6 +58,9 @@ var EmptyStateController = class extends Controller {
|
|
|
42
58
|
if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
|
|
43
59
|
if (this.#empty !== null && empty !== this.#empty) {
|
|
44
60
|
this.dispatch("change", { detail: { count, empty } });
|
|
61
|
+
announce(
|
|
62
|
+
fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
|
|
63
|
+
);
|
|
45
64
|
}
|
|
46
65
|
this.#empty = empty;
|
|
47
66
|
}
|
|
@@ -55,11 +74,6 @@ var EmptyStateController = class extends Controller {
|
|
|
55
74
|
return this.listTarget.childElementCount;
|
|
56
75
|
}
|
|
57
76
|
}
|
|
58
|
-
#isLiveRegion(el) {
|
|
59
|
-
if (el.hasAttribute("aria-live")) return true;
|
|
60
|
-
const role = el.getAttribute("role");
|
|
61
|
-
return role === "status" || role === "alert";
|
|
62
|
-
}
|
|
63
77
|
};
|
|
64
78
|
|
|
65
79
|
export { EmptyStateController };
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/focus_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|