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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/dist/controllers/alert_dialog_controller.js +32 -5
  4. data/dist/controllers/announcer_controller.js +255 -20
  5. data/dist/controllers/color_picker_controller.js +46 -0
  6. data/dist/controllers/command_palette_controller.js +32 -5
  7. data/dist/controllers/confirm_controller.js +32 -5
  8. data/dist/controllers/countdown_controller.js +112 -12
  9. data/dist/controllers/date_range_picker_controller.js +50 -0
  10. data/dist/controllers/dialog_controller.js +32 -5
  11. data/dist/controllers/drawer_controller.js +32 -5
  12. data/dist/controllers/empty_state_controller.js +24 -10
  13. data/dist/controllers/focus_controller.js +32 -5
  14. data/dist/controllers/frame_loading_controller.js +177 -15
  15. data/dist/controllers/local_time_controller.js +100 -6
  16. data/dist/controllers/meter_controller.js +145 -26
  17. data/dist/controllers/network_status_controller.js +28 -8
  18. data/dist/controllers/overflow_menu_controller.js +33 -6
  19. data/dist/controllers/progress_controller.js +116 -9
  20. data/dist/controllers/range_slider_controller.js +68 -3
  21. data/dist/controllers/rating_controller.js +53 -0
  22. data/dist/controllers/relative_time_controller.js +133 -12
  23. data/dist/controllers/sidebar_controller.js +37 -8
  24. data/dist/controllers/skeleton_controller.js +73 -20
  25. data/dist/controllers/slider_controller.js +17 -2
  26. data/dist/controllers/spinner_controller.js +228 -27
  27. data/dist/controllers/step_indicator_controller.js +82 -5
  28. data/dist/controllers/stick_to_bottom_controller.js +60 -10
  29. data/dist/index.js +1056 -281
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
@@ -2,6 +2,68 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/relative_time_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
+
34
+ // src/utils/microtask_coalescer.ts
35
+ var MicrotaskCoalescer = class {
36
+ #run;
37
+ #queued = false;
38
+ #active = false;
39
+ #generation = 0;
40
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
41
+ constructor(run) {
42
+ this.#run = run;
43
+ }
44
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
45
+ activate() {
46
+ this.#active = true;
47
+ }
48
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
49
+ cancel() {
50
+ this.#active = false;
51
+ this.#queued = false;
52
+ this.#generation += 1;
53
+ }
54
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
55
+ schedule() {
56
+ if (!this.#active || this.#queued) return;
57
+ this.#queued = true;
58
+ const generation = this.#generation;
59
+ queueMicrotask(() => {
60
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
61
+ this.#queued = false;
62
+ this.#run();
63
+ });
64
+ }
65
+ };
66
+
5
67
  // src/utils/safe_timeout.ts
6
68
  var TimerRegistry = class {
7
69
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -73,11 +135,16 @@ var RelativeTimeController = class extends Controller {
73
135
  tickInterval: { type: Number, default: 6e4 }
74
136
  };
75
137
  #timers = new SafeTimeout();
138
+ /** Collapses a morph that swaps several render inputs at once into one repaint. */
139
+ #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
140
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
76
141
  /** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */
77
142
  #targetMs = Number.NaN;
78
143
  /** The authored absolute text, restored when the threshold fallback kicks in. */
79
144
  #absoluteText = "";
80
145
  connect() {
146
+ this.#resync.activate();
147
+ this.#beforeCache.activate();
81
148
  if (this.element.getAttribute("data-state") !== "relative") {
82
149
  this.#absoluteText = (this.element.textContent ?? "").trim();
83
150
  }
@@ -86,9 +153,50 @@ var RelativeTimeController = class extends Controller {
86
153
  this.#schedule();
87
154
  }
88
155
  disconnect() {
156
+ this.#resync.cancel();
157
+ this.#beforeCache.deactivate();
158
+ this.#timers.clearAll();
159
+ }
160
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
161
+ localeValueChanged() {
162
+ this.#resync.schedule();
163
+ }
164
+ /** Repaints when application code (or a Turbo morph) changes `threshold` at runtime. */
165
+ thresholdValueChanged() {
166
+ this.#resync.schedule();
167
+ }
168
+ /** Repaints when application code (or a Turbo morph) changes `tickInterval` at runtime. */
169
+ tickIntervalValueChanged() {
170
+ this.#resync.schedule();
171
+ }
172
+ /**
173
+ * Renders against the current Values and re-arms the poll from now.
174
+ *
175
+ * Render only: it emits no event, and clearing first keeps the single self-arming
176
+ * timer single — scheduling on top of a pending one would double the poll rate for
177
+ * the rest of the session. A stamp whose `datetime` never parsed has nothing to
178
+ * render, and one that already reached its terminal fallback simply renders it
179
+ * again and stops.
180
+ */
181
+ #resyncToValues() {
182
+ if (Number.isNaN(this.#targetMs)) return;
89
183
  this.#timers.clearAll();
184
+ this.#schedule();
185
+ }
186
+ /**
187
+ * Restores the authored absolute text and the pre-render state for the snapshot
188
+ * Turbo is about to take, leaving the live page's poll timer alone.
189
+ *
190
+ * With no authored text held there is nothing to restore, and `data-state` has to
191
+ * stay as it is: that marker is what tells the next `connect()` the visible text is
192
+ * a rendered relative form rather than an absolute fallback to hold on to.
193
+ */
194
+ #rewindForCache() {
195
+ if (!this.#absoluteText) return;
196
+ this.element.textContent = this.#absoluteText;
197
+ this.element.removeAttribute("data-state");
90
198
  }
91
- /** Renders the current representation and reschedules unless it is now absolute. */
199
+ /** Renders the current representation and reschedules unless polling can stop. */
92
200
  #schedule() {
93
201
  const nextDelay = this.#applyAndComputeDelay();
94
202
  if (nextDelay !== null) {
@@ -96,31 +204,44 @@ var RelativeTimeController = class extends Controller {
96
204
  }
97
205
  }
98
206
  /**
99
- * Updates the visible text and returns the next poll delay (ms), or `null`
100
- * once the absolute fallback is shown (which never changes, so stop polling).
207
+ * Updates the visible text and returns the next poll delay (ms), or `null` when
208
+ * polling can stop: a *past* timestamp that fell back to the absolute text can
209
+ * never leave it again, and a locale the runtime rejects has nothing to render
210
+ * until that value is corrected.
101
211
  */
102
212
  #applyAndComputeDelay() {
103
213
  const deltaMs = this.#targetMs - Date.now();
104
214
  const absSeconds = Math.abs(deltaMs) / 1e3;
215
+ const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
216
+ const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
217
+ const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
105
218
  if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
106
219
  this.element.textContent = this.#absoluteText;
107
220
  this.element.setAttribute("data-state", "absolute");
108
- return null;
221
+ if (deltaMs <= 0) return null;
222
+ return Math.min(nextDelay, deltaMs - this.thresholdValue * 1e3 + 1);
109
223
  }
110
- const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
224
+ const formatter = this.#formatter;
225
+ if (formatter === null) return null;
111
226
  const value = Math.round(deltaMs / scale.ms);
112
- this.element.textContent = this.#formatter.format(value, scale.unit);
227
+ this.element.textContent = formatter.format(value, scale.unit);
113
228
  this.element.setAttribute("data-state", "relative");
114
- const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
115
- return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
229
+ return nextDelay;
116
230
  }
117
- /** A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`). */
231
+ /**
232
+ * A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`), or `null`
233
+ * when the runtime rejects that locale.
234
+ */
118
235
  get #formatter() {
119
- return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
236
+ try {
237
+ return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
238
+ } catch {
239
+ return null;
240
+ }
120
241
  }
121
- /** Locale precedence: the value, then the element's `lang`, then the document's. */
242
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
122
243
  get #locale() {
123
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
244
+ return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
124
245
  }
125
246
  };
126
247
 
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/sidebar_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
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
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
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
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
- #onBeforeCache = () => {
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
@@ -474,7 +501,7 @@ var SidebarController = class extends Controller {
474
501
  key: { type: String, default: "" },
475
502
  collapsed: { type: Boolean, default: false }
476
503
  };
477
- static actions = ["beforeCache", "close", "open", "toggle"];
504
+ static actions = ["close", "open", "toggle"];
478
505
  /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
479
506
  #activePanel = null;
480
507
  /** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
@@ -496,6 +523,7 @@ var SidebarController = class extends Controller {
496
523
  #connected = false;
497
524
  connect() {
498
525
  this.#connected = true;
526
+ this.#beforeCache.activate();
499
527
  this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
500
528
  this.#collapsed = this.#restoreCollapsed();
501
529
  this.#mqlQuery = this.#breakpointQuery;
@@ -505,6 +533,7 @@ var SidebarController = class extends Controller {
505
533
  }
506
534
  disconnect() {
507
535
  this.#connected = false;
536
+ this.#beforeCache.deactivate();
508
537
  this.#mql?.removeEventListener("change", this.#onMediaChange);
509
538
  this.#mql = null;
510
539
  this.#mqlQuery = null;
@@ -556,12 +585,12 @@ var SidebarController = class extends Controller {
556
585
  * closed immediately, and modal side effects are released without moving
557
586
  * focus during navigation.
558
587
  */
559
- beforeCache() {
588
+ #beforeCache = new BeforeCacheReset(() => {
560
589
  if (!this.#connected || !this.#isOverlay) return;
561
590
  this.#transition.cancel();
562
591
  this.#trap.deactivate({ restoreFocus: false });
563
592
  this.#setOverlayClosedImmediate();
564
- }
593
+ });
565
594
  /** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
566
595
  toggle() {
567
596
  if (this.#isOverlay) {
@@ -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
- /** Pending min-duration reveal timer id, or `null` when none is scheduled. */
68
- #revealTimerId = null;
69
- /** Epoch ms when the loading state began; `minDuration` is measured from it. */
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.#revealTimerId = null;
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.#revealTimerId !== null) return;
83
- const remaining = this.minDurationValue - (Date.now() - this.#loadingSince);
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
- if (this.#revealTimerId !== null) {
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.#loadingSince = Date.now();
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() {
@@ -18,6 +18,22 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
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
+
21
37
  // src/controllers/slider_controller.ts
22
38
  var FRACTION_PROPERTY = "--stimeo--slider-fraction";
23
39
  var SliderController = class extends Controller {
@@ -123,8 +139,7 @@ var SliderController = class extends Controller {
123
139
  this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
124
140
  this.thumbTarget.setAttribute("aria-valuenow", String(value));
125
141
  }
126
- const span = this.maxValue - this.minValue;
127
- const fraction = span > 0 ? (value - this.minValue) / span : 0;
142
+ const fraction = rangeFraction(value, this.minValue, this.maxValue);
128
143
  this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
129
144
  if (changed && !silent) this.dispatch("change", { detail: { value } });
130
145
  }