stimeo-ui 0.14.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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +81 -12
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +85 -17
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +46 -13
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +432 -71
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -83,6 +83,16 @@ var BeforeCacheReset = class _BeforeCacheReset {
83
83
  }
84
84
  };
85
85
 
86
+ // src/utils/field_mirror.ts
87
+ function writeField(field, value) {
88
+ if (field.value === value) return false;
89
+ field.value = value;
90
+ return true;
91
+ }
92
+ function commitField(target) {
93
+ target.dispatchEvent(new Event("change", { bubbles: true }));
94
+ }
95
+
86
96
  // src/utils/microtask_coalescer.ts
87
97
  var MicrotaskCoalescer = class {
88
98
  #run;
@@ -201,11 +211,14 @@ var RatingController = class extends Controller {
201
211
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
202
212
  #connected = false;
203
213
  #rescuedFocus = false;
214
+ /** The rating shown last, which the next move is measured from. */
215
+ #shown = 0;
204
216
  /** Reflects declarative state without announcing an initial user change. */
205
217
  connect() {
206
218
  this.#repaint.activate();
207
219
  this.#beforeCache.activate();
208
- this.#apply(this.#normalize(this.valueValue), { focus: false });
220
+ this.#shown = this.#normalize(this.valueValue);
221
+ this.#apply(this.#shown, { focus: false });
209
222
  this.#connected = true;
210
223
  }
211
224
  /** Drops a queued reconciliation and hands every borrowed attribute back. */
@@ -306,29 +319,35 @@ var RatingController = class extends Controller {
306
319
  this.#commit(next, { focus: true });
307
320
  }
308
321
  /**
309
- * Repaints one settled target/Value mutation batch and reports only a value
310
- * this controller had to normalize.
322
+ * Repaints one settled target/Value mutation batch and reports a rating shown
323
+ * that moved from the one shown before it.
311
324
  *
312
325
  * @stimeoRenderRoot
313
326
  */
314
327
  #reconcileScale() {
315
- const requested = this.valueValue;
316
- const value = this.#normalize(requested);
328
+ const value = this.#normalize(this.valueValue);
329
+ const previous = this.#shown;
330
+ this.#shown = value;
317
331
  this.#apply(value, { focus: false });
318
- if (!Object.is(value, requested)) {
319
- this.dispatch("reconcile", { detail: { value } });
320
- }
332
+ if (value !== previous) this.dispatch("reconcile", { detail: { value } });
321
333
  }
322
- /** Applies one user operation and emits only when its committed value changes. */
334
+ /**
335
+ * Applies one user operation and emits only when the rating shown changes.
336
+ *
337
+ * The user's value is written into `value` before anything reports it, so a
338
+ * listener of the field's native `change` already reads it there, and the pass
339
+ * that the write starts finds nothing left to report.
340
+ */
323
341
  #commit(raw, { focus }) {
324
- const previous = this.#normalize(this.valueValue);
342
+ const previous = this.#shown;
325
343
  const value = this.#normalize(raw);
326
- this.#apply(value, { focus });
344
+ if (!Object.is(this.valueValue, value)) this.valueValue = value;
345
+ this.#shown = value;
346
+ this.#apply(value, { focus, notify: true });
327
347
  if (value !== previous) this.dispatch("change", { detail: { value } });
328
348
  }
329
- /** Synchronizes value, ARIA, roving focus, form state, and the visual fill hook. */
330
- #apply(value, { focus }) {
331
- if (!Object.is(this.valueValue, value)) this.valueValue = value;
349
+ /** Synchronizes ARIA, roving focus, form state, and the visual fill hook. */
350
+ #apply(value, { focus, notify = false }) {
332
351
  this.symbolTargets.forEach((symbol, index) => {
333
352
  symbol.setAttribute("aria-checked", value > 0 && index + 1 === value ? "true" : "false");
334
353
  });
@@ -338,9 +357,14 @@ var RatingController = class extends Controller {
338
357
  const returning = this.#releaseReadonly();
339
358
  this.#roving.setActive(value > 0 ? value - 1 : 0, { focus: focus || returning });
340
359
  }
341
- if (this.hasFieldTarget) this.fieldTarget.value = String(value);
360
+ this.#mirrorField(value, notify);
342
361
  this.#setFillRange(value);
343
362
  }
363
+ /** Mirrors `value` into the optional form field, reporting only a user's move. */
364
+ #mirrorField(value, notify) {
365
+ if (!this.hasFieldTarget) return;
366
+ if (writeField(this.fieldTarget, String(value)) && notify) commitField(this.fieldTarget);
367
+ }
344
368
  /** Marks the first `range` symbols with the consumer-owned fill hook. */
345
369
  #setFillRange(range) {
346
370
  this.symbolTargets.forEach((symbol, index) => {
@@ -63,10 +63,15 @@ var LayoutObserver = class {
63
63
  #resizeObserverFactory;
64
64
  #resizeObserver = null;
65
65
  #observingViewport = false;
66
+ #loadContainer = null;
66
67
  /** Stable bound handler so add/removeEventListener target the same reference. */
67
68
  #handleViewportResize = () => {
68
69
  this.#callback();
69
70
  };
71
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
72
+ #handleDescendantLoad = () => {
73
+ this.#callback();
74
+ };
70
75
  constructor(callback, options = {}) {
71
76
  this.#callback = callback;
72
77
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -102,24 +107,97 @@ var LayoutObserver = class {
102
107
  window.removeEventListener("resize", this.#handleViewportResize);
103
108
  }
104
109
  /**
105
- * Releases every observation: disconnects the {@link ResizeObserver} and
106
- * removes the viewport listener. Safe to call multiple times. Call this from a
107
- * controller's `disconnect()`.
110
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
111
+ * frame settling changes the box it sits in, and it measures as zero high until
112
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
113
+ * on the container itself and nothing the caller spells.
114
+ *
115
+ * **One container at a time.** A further call moves the observation, so a widget
116
+ * whose content element is swapped at runtime releases the element it let go by
117
+ * naming the new one — there is no second place for the release to drift from.
118
+ */
119
+ observeDescendantLoads(container) {
120
+ this.unobserveDescendantLoads();
121
+ this.#loadContainer = container;
122
+ container.addEventListener("load", this.#handleDescendantLoad, true);
123
+ }
124
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
125
+ unobserveDescendantLoads() {
126
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
127
+ this.#loadContainer = null;
128
+ }
129
+ /**
130
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
131
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
132
+ * this from a controller's `disconnect()`.
108
133
  */
109
134
  disconnect() {
110
135
  this.#resizeObserver?.disconnect();
111
136
  this.#resizeObserver = null;
112
137
  this.unobserveViewport();
138
+ this.unobserveDescendantLoads();
139
+ }
140
+ };
141
+
142
+ // src/utils/state_reason.ts
143
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
144
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
145
+ function stateReasonFor(event) {
146
+ if (!event) return "api";
147
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
148
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
149
+ return "user";
150
+ }
151
+
152
+ // src/utils/state_regions.ts
153
+ var StateRegions = class {
154
+ #whenTrue;
155
+ #whenFalse;
156
+ #taken = /* @__PURE__ */ new WeakSet();
157
+ constructor(options) {
158
+ this.#whenTrue = options.whenTrue;
159
+ this.#whenFalse = options.whenFalse ?? null;
160
+ }
161
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
162
+ reflect(host, isTrue) {
163
+ const shown = this.#inside(host, this.#whenTrue());
164
+ if (!this.#whenFalse) {
165
+ for (const region of shown) this.#write(region, !isTrue);
166
+ return;
167
+ }
168
+ const hiddenSide = this.#inside(host, this.#whenFalse());
169
+ if (shown.length === 0 || hiddenSide.length === 0) {
170
+ for (const region of shown) this.#give(region);
171
+ for (const region of hiddenSide) this.#give(region);
172
+ return;
173
+ }
174
+ for (const region of shown) this.#write(region, !isTrue);
175
+ for (const region of hiddenSide) this.#write(region, isTrue);
176
+ }
177
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
178
+ #write(region, hidden) {
179
+ if (hidden) this.#taken.add(region);
180
+ else this.#taken.delete(region);
181
+ if (region.hidden !== hidden) region.hidden = hidden;
182
+ }
183
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
184
+ #give(region) {
185
+ if (this.#taken.delete(region)) region.hidden = false;
186
+ }
187
+ /** The declared regions that sit within `host`, which may be the host itself. */
188
+ #inside(host, regions) {
189
+ return regions.filter((region) => host.contains(region));
113
190
  }
114
191
  };
115
192
 
116
193
  // src/controllers/read_more_controller.ts
117
194
  var ReadMoreController = class extends Controller {
118
- static targets = ["content", "trigger"];
195
+ static targets = ["content", "trigger", "expandedLabel", "collapsedLabel"];
119
196
  static values = {
120
197
  collapsed: { type: Boolean, default: true }
121
198
  };
122
199
  static actions = ["toggle"];
200
+ static events = ["close", "open"];
123
201
  #connected = false;
124
202
  #collapsed = true;
125
203
  #observedContent = null;
@@ -128,6 +206,11 @@ var ReadMoreController = class extends Controller {
128
206
  if (this.#connected) this.#evaluateOverflow();
129
207
  };
130
208
  #layout = new LayoutObserver(this.#update);
209
+ /** Owns `hidden` on the trigger's two labels. */
210
+ #labels = new StateRegions({
211
+ whenTrue: () => this.expandedLabelTargets,
212
+ whenFalse: () => this.collapsedLabelTargets
213
+ });
131
214
  /** Holds the trigger's hide back while it has focus; re-evaluates on blur. */
132
215
  #deferredHide = new BlurDeferral(() => {
133
216
  this.#update();
@@ -155,12 +238,21 @@ var ReadMoreController = class extends Controller {
155
238
  this.#deferredHide.release(trigger);
156
239
  this.#syncTargets();
157
240
  }
241
+ expandedLabelTargetConnected() {
242
+ this.#syncTargets();
243
+ }
244
+ collapsedLabelTargetConnected() {
245
+ this.#syncTargets();
246
+ }
158
247
  /** Toggles between the collapsed (clamped) and expanded states. */
159
- toggle() {
248
+ toggle(event) {
160
249
  if (!this.#connected) return;
161
250
  this.#collapsed = !this.#collapsed;
162
251
  this.#reflect();
163
252
  this.#evaluateOverflow();
253
+ const detail = { reason: stateReasonFor(event) };
254
+ if (this.#collapsed) this.dispatch("close", { detail, cancelable: false });
255
+ else this.dispatch("open", { detail, cancelable: false });
164
256
  }
165
257
  #initialCollapsed() {
166
258
  if (this.hasContentTarget) {
@@ -176,6 +268,7 @@ var ReadMoreController = class extends Controller {
176
268
  }
177
269
  if (this.hasTriggerTarget) {
178
270
  this.triggerTarget.setAttribute("aria-expanded", this.#collapsed ? "false" : "true");
271
+ this.#labels.reflect(this.triggerTarget, !this.#collapsed);
179
272
  }
180
273
  }
181
274
  #syncTargets() {
@@ -192,7 +285,7 @@ var ReadMoreController = class extends Controller {
192
285
  this.#observedContent = next;
193
286
  this.#layout.observe(next);
194
287
  this.#layout.observeViewport();
195
- next.addEventListener("load", this.#update, true);
288
+ this.#layout.observeDescendantLoads(next);
196
289
  if (typeof MutationObserver !== "undefined") {
197
290
  this.#contentMutationObserver = new MutationObserver(this.#update);
198
291
  this.#contentMutationObserver.observe(next, {
@@ -206,7 +299,7 @@ var ReadMoreController = class extends Controller {
206
299
  this.#deferredHide.releaseAll();
207
300
  if (this.#observedContent) {
208
301
  this.#layout.unobserve(this.#observedContent);
209
- this.#observedContent.removeEventListener("load", this.#update, true);
302
+ this.#layout.unobserveDescendantLoads();
210
303
  }
211
304
  this.#observedContent = null;
212
305
  this.#contentMutationObserver?.disconnect();
@@ -31,16 +31,51 @@ var BeforeCacheReset = class _BeforeCacheReset {
31
31
  }
32
32
  };
33
33
 
34
+ // src/utils/frame_coalescer.ts
35
+ var FrameCoalescer = class {
36
+ #frame = null;
37
+ /**
38
+ * Runs `run` on the next frame, unless a frame is already pending — the first
39
+ * request of a burst wins and the rest are dropped. The pending frame is
40
+ * released before `run`, so `run` may request the next one.
41
+ */
42
+ schedule(run) {
43
+ if (this.#frame !== null) return;
44
+ this.#frame = requestAnimationFrame(() => {
45
+ this.#frame = null;
46
+ run();
47
+ });
48
+ }
49
+ /**
50
+ * Drops the pending frame, and reaches the platform only when there is one.
51
+ *
52
+ * There is no handle value that stands for "nothing pending":
53
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
54
+ * arrives as a large positive number that the same allocator can hand out, and
55
+ * an idle cancel would then drop a frame belonging to someone else.
56
+ */
57
+ cancel() {
58
+ if (this.#frame === null) return;
59
+ cancelAnimationFrame(this.#frame);
60
+ this.#frame = null;
61
+ }
62
+ };
63
+
34
64
  // src/utils/layout_observer.ts
35
65
  var LayoutObserver = class {
36
66
  #callback;
37
67
  #resizeObserverFactory;
38
68
  #resizeObserver = null;
39
69
  #observingViewport = false;
70
+ #loadContainer = null;
40
71
  /** Stable bound handler so add/removeEventListener target the same reference. */
41
72
  #handleViewportResize = () => {
42
73
  this.#callback();
43
74
  };
75
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
76
+ #handleDescendantLoad = () => {
77
+ this.#callback();
78
+ };
44
79
  constructor(callback, options = {}) {
45
80
  this.#callback = callback;
46
81
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -76,14 +111,35 @@ var LayoutObserver = class {
76
111
  window.removeEventListener("resize", this.#handleViewportResize);
77
112
  }
78
113
  /**
79
- * Releases every observation: disconnects the {@link ResizeObserver} and
80
- * removes the viewport listener. Safe to call multiple times. Call this from a
81
- * controller's `disconnect()`.
114
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
115
+ * frame settling changes the box it sits in, and it measures as zero high until
116
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
117
+ * on the container itself and nothing the caller spells.
118
+ *
119
+ * **One container at a time.** A further call moves the observation, so a widget
120
+ * whose content element is swapped at runtime releases the element it let go by
121
+ * naming the new one — there is no second place for the release to drift from.
122
+ */
123
+ observeDescendantLoads(container) {
124
+ this.unobserveDescendantLoads();
125
+ this.#loadContainer = container;
126
+ container.addEventListener("load", this.#handleDescendantLoad, true);
127
+ }
128
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
129
+ unobserveDescendantLoads() {
130
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
131
+ this.#loadContainer = null;
132
+ }
133
+ /**
134
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
135
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
136
+ * this from a controller's `disconnect()`.
82
137
  */
83
138
  disconnect() {
84
139
  this.#resizeObserver?.disconnect();
85
140
  this.#resizeObserver = null;
86
141
  this.unobserveViewport();
142
+ this.unobserveDescendantLoads();
87
143
  }
88
144
  };
89
145
 
@@ -148,18 +204,13 @@ var ReadingProgressController = class extends Controller {
148
204
  /** The article's own box and the viewport: either changes the span. */
149
205
  #layout = new LayoutObserver(() => this.#onScroll());
150
206
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
151
- #frame = null;
207
+ /** Coalesces scroll bursts, and the connect baseline, into one frame. */
208
+ #frames = new FrameCoalescer();
152
209
  /** Last published progress, so `change`/`complete` fire only on movement. */
153
210
  #progress = -1;
154
211
  /** False until the connect frame has run: `complete` needs real reading. */
155
212
  #baselined = false;
156
- #onScroll = () => {
157
- if (this.#frame !== null) return;
158
- this.#frame = requestAnimationFrame(() => {
159
- this.#frame = null;
160
- this.#measure();
161
- });
162
- };
213
+ #onScroll = () => this.#frames.schedule(() => this.#measure());
163
214
  connect() {
164
215
  this.#progress = -1;
165
216
  this.#baselined = false;
@@ -168,8 +219,7 @@ var ReadingProgressController = class extends Controller {
168
219
  this.#layout.observeViewport();
169
220
  this.#beforeCache.activate();
170
221
  this.#measure();
171
- this.#frame = requestAnimationFrame(() => {
172
- this.#frame = null;
222
+ this.#frames.schedule(() => {
173
223
  this.#measure();
174
224
  this.#baselined = true;
175
225
  });
@@ -178,7 +228,7 @@ var ReadingProgressController = class extends Controller {
178
228
  window.removeEventListener("scroll", this.#onScroll, { capture: true });
179
229
  this.#layout.disconnect();
180
230
  this.#beforeCache.deactivate();
181
- this.#cancelFrame();
231
+ this.#frames.cancel();
182
232
  this.#lease.returnAll();
183
233
  }
184
234
  /**
@@ -189,14 +239,10 @@ var ReadingProgressController = class extends Controller {
189
239
  * value that has already been handed back.
190
240
  */
191
241
  #rewindForCache() {
192
- this.#cancelFrame();
242
+ this.#frames.cancel();
193
243
  this.#lease.returnAll();
194
244
  this.#progress = -1;
195
245
  }
196
- #cancelFrame() {
197
- if (this.#frame !== null) cancelAnimationFrame(this.#frame);
198
- this.#frame = null;
199
- }
200
246
  /** Computes and publishes the progress; emits on movement only. */
201
247
  #measure() {
202
248
  const rect = this.element.getBoundingClientRect();
@@ -60,6 +60,11 @@ function construct(factory, locale, options, fallbackLocale) {
60
60
  }
61
61
  }
62
62
 
63
+ // src/utils/locale.ts
64
+ function resolveLocale(element, declared = "") {
65
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
66
+ }
67
+
63
68
  // src/utils/microtask_coalescer.ts
64
69
  var MicrotaskCoalescer = class {
65
70
  #run;
@@ -265,11 +270,11 @@ var RelativeTimeController = class extends Controller {
265
270
  * when the runtime rejects that locale.
266
271
  */
267
272
  get #formatter() {
268
- return intlFormatter(Intl.RelativeTimeFormat, this.#locale, RELATIVE_OPTIONS);
269
- }
270
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
271
- get #locale() {
272
- return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
273
+ return intlFormatter(
274
+ Intl.RelativeTimeFormat,
275
+ resolveLocale(this.element, this.localeValue),
276
+ RELATIVE_OPTIONS
277
+ );
273
278
  }
274
279
  };
275
280
 
@@ -107,7 +107,20 @@ var TabindexLoan = class {
107
107
  }
108
108
  };
109
109
 
110
+ // src/utils/transient_hooks.ts
111
+ var TransientHooks = class {
112
+ #attributes;
113
+ constructor(options) {
114
+ this.#attributes = options.attributes;
115
+ }
116
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
117
+ reset(element) {
118
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
119
+ }
120
+ };
121
+
110
122
  // src/controllers/resizable_controller.ts
123
+ var TRANSIENT = new TransientHooks({ attributes: ["data-dragging"] });
111
124
  var DEFAULT_MIN = 0;
112
125
  var DEFAULT_MAX = 100;
113
126
  var DEFAULT_VALUE = 50;
@@ -121,9 +134,20 @@ var ResizableController = class extends Controller {
121
134
  value: { type: Number, default: DEFAULT_VALUE }
122
135
  };
123
136
  static actions = ["onKeydown", "onPointerDown", "toggle"];
124
- static events = ["change"];
137
+ static events = ["change", "reconcile"];
125
138
  /** Where the divider sat before the current collapse; `null` when unknown. */
126
139
  #valueBeforeCollapse = null;
140
+ /**
141
+ * The position last painted: moved by each move the user makes — every step of
142
+ * a drag included — and by each repaint that finds a page-driven move.
143
+ */
144
+ #painted = DEFAULT_VALUE;
145
+ /**
146
+ * The position last confirmed: the one connecting published, then moved by
147
+ * each `change` and `reconcile`. A drag moves the painted position step by
148
+ * step and this one only when it ends.
149
+ */
150
+ #confirmed = DEFAULT_VALUE;
127
151
  /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
128
152
  #dragAbort = null;
129
153
  /** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
@@ -133,8 +157,9 @@ var ResizableController = class extends Controller {
133
157
  /** Folds the range and position inputs of one morph batch into a single paint. */
134
158
  #repaint = new MicrotaskCoalescer(() => this.#render());
135
159
  connect() {
136
- this.element.removeAttribute("data-dragging");
160
+ TRANSIENT.reset(this.element);
137
161
  this.#repaint.activate();
162
+ this.#confirmed = this.#position;
138
163
  this.#render();
139
164
  this.#cycleAbort = new AbortController();
140
165
  this.element.addEventListener("keydown", this.#onCycleKeydown, {
@@ -251,9 +276,7 @@ var ResizableController = class extends Controller {
251
276
  }
252
277
  if (handled) {
253
278
  event.preventDefault();
254
- this.valueValue = Math.max(min, Math.min(nextValue, max));
255
- this.#render();
256
- this.#dispatchChange();
279
+ this.#commit(nextValue);
257
280
  }
258
281
  }
259
282
  /** Collapses the primary pane to its minimum, or returns it to the last position. */
@@ -261,22 +284,19 @@ var ResizableController = class extends Controller {
261
284
  const { min, max } = this.#range;
262
285
  if (this.#position > min) {
263
286
  this.#valueBeforeCollapse = this.#position;
264
- this.valueValue = min;
287
+ this.#commit(min);
265
288
  } else {
266
- this.valueValue = this.#valueBeforeCollapse ?? max;
289
+ const restored = this.#valueBeforeCollapse ?? max;
267
290
  this.#valueBeforeCollapse = null;
291
+ this.#commit(restored);
268
292
  }
269
- this.#render();
270
- this.#dispatchChange();
271
293
  }
272
294
  #onPointerMove = (event) => {
273
295
  if (!this.hasSeparatorTarget) return;
274
296
  const rect = this.element.getBoundingClientRect();
275
297
  const raw = this.#isVertical ? (event.clientX - rect.left) / rect.width : (event.clientY - rect.top) / rect.height;
276
298
  const fraction = Number.isFinite(raw) ? Math.max(0, Math.min(raw, 1)) : 0;
277
- const { min, max } = this.#range;
278
- this.valueValue = Math.max(min, Math.min(Math.round(fraction * 100), max));
279
- this.#render();
299
+ this.#move(Math.round(fraction * 100));
280
300
  };
281
301
  #onPointerUp = (event) => {
282
302
  this.element.removeAttribute("data-dragging");
@@ -285,21 +305,50 @@ var ResizableController = class extends Controller {
285
305
  if (this.hasSeparatorTarget) {
286
306
  this.separatorTarget.releasePointerCapture(event.pointerId);
287
307
  }
288
- this.#dispatchChange();
308
+ this.#confirm();
289
309
  };
310
+ /** Moves to a position the user chose and reports it if it moved. */
311
+ #commit(raw) {
312
+ this.#move(raw);
313
+ this.#confirm();
314
+ }
290
315
  /**
291
- * Publishes the position: the fraction consumer CSS multiplies a pane by, and
292
- * the range assistive tech reads off the separator. The clamped position is
293
- * written back to the Value so it, ARIA, CSS, and dispatched events agree.
316
+ * Stores the clamped position the user moved to and paints it. The painted
317
+ * position moves with it before anything is reported, so the repaint the Value
318
+ * write schedules — and any move a subscriber makes while `change` is
319
+ * dispatched — is measured from this position.
320
+ */
321
+ #move(raw) {
322
+ const { min, max } = this.#range;
323
+ const position = Math.max(min, Math.min(raw, max));
324
+ this.valueValue = position;
325
+ this.#painted = position;
326
+ this.#paint();
327
+ }
328
+ /**
329
+ * Publishes the position the Values declare without writing it back. A
330
+ * position the user did not paint is the page's move, reported once as
331
+ * `reconcile` unless it lands on the position last confirmed — a drag the page
332
+ * puts back where it started has nothing new to report.
294
333
  *
295
334
  * @stimeoRenderRoot
296
335
  */
297
336
  #render() {
337
+ this.#paint();
338
+ const position = this.#position;
339
+ if (position === this.#painted) return;
340
+ this.#painted = position;
341
+ if (position === this.#confirmed) return;
342
+ this.#confirmed = position;
343
+ this.dispatch("reconcile", { detail: this.#detail(position) });
344
+ }
345
+ /**
346
+ * Publishes the position: the fraction consumer CSS multiplies a pane by, and
347
+ * the range assistive tech reads off the separator.
348
+ */
349
+ #paint() {
298
350
  const { min, max } = this.#range;
299
351
  const position = this.#position;
300
- if (this.valueValue !== position) {
301
- this.valueValue = position;
302
- }
303
352
  this.element.style.setProperty("--stimeo--resizable-fraction", String(position / 100));
304
353
  if (this.hasSeparatorTarget) {
305
354
  this.separatorTarget.setAttribute("aria-valuenow", String(position));
@@ -307,9 +356,20 @@ var ResizableController = class extends Controller {
307
356
  this.separatorTarget.setAttribute("aria-valuemax", String(max));
308
357
  }
309
358
  }
310
- #dispatchChange() {
311
- const fraction = this.valueValue / 100;
312
- this.dispatch("change", { detail: { value: this.valueValue, fraction } });
359
+ /**
360
+ * Reports the published position as `change` when the user's operation moved
361
+ * it from the position last confirmed. A key at an edge, a press released
362
+ * without moving and a drag released where it started report nothing.
363
+ */
364
+ #confirm() {
365
+ const position = this.#position;
366
+ if (position === this.#confirmed) return;
367
+ this.#confirmed = position;
368
+ this.dispatch("change", { detail: this.#detail(position) });
369
+ }
370
+ /** The detail both events carry for `position`. */
371
+ #detail(position) {
372
+ return { value: position, fraction: position / 100 };
313
373
  }
314
374
  /** The declared range after validation; an unreadable bound uses its default. */
315
375
  get #range() {