stimeo-ui 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -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 +54 -10
  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 +56 -11
  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 +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  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 +14 -1
  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 +305 -46
  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 +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  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/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -18,6 +18,16 @@ 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/field_mirror.ts
22
+ function writeField(field, value) {
23
+ if (field.value === value) return false;
24
+ field.value = value;
25
+ return true;
26
+ }
27
+ function commitField(target) {
28
+ target.dispatchEvent(new Event("change", { bubbles: true }));
29
+ }
30
+
21
31
  // src/utils/microtask_coalescer.ts
22
32
  var MicrotaskCoalescer = class {
23
33
  #run;
@@ -53,54 +63,46 @@ var MicrotaskCoalescer = class {
53
63
 
54
64
  // src/utils/owned_pointer_session.ts
55
65
  var OwnedPointerSession = class {
56
- pointerId;
66
+ #pointerId;
57
67
  #owner;
58
68
  #handlers;
59
69
  #abort = new AbortController();
60
70
  #active = true;
61
71
  constructor(start, owner, handlers) {
62
- this.pointerId = start.pointerId;
72
+ this.#pointerId = start.pointerId;
63
73
  this.#owner = owner;
64
74
  this.#handlers = handlers;
65
75
  const { signal } = this.#abort;
66
76
  owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
67
77
  owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
68
78
  owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
69
- owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
70
79
  try {
71
- owner.setPointerCapture?.(this.pointerId);
80
+ owner.setPointerCapture?.(this.#pointerId);
72
81
  } catch {
73
82
  }
74
83
  }
75
- /** Whether this session still owns its pointer and listeners. */
76
- get active() {
77
- return this.#active;
78
- }
79
- /** Whether `event` belongs to the initiating pointer of the live session. */
80
- owns(event) {
81
- return this.#active && event.pointerId === this.pointerId;
82
- }
83
- /** Releases capture/listeners and invokes the end callback exactly once. */
84
+ /** Releases capture and listeners, then reports the end exactly once. */
84
85
  end() {
86
+ this.#finish("teardown");
87
+ }
88
+ #finish(kind) {
85
89
  if (!this.#active) return;
86
90
  this.#active = false;
87
91
  this.#abort.abort();
88
92
  try {
89
- this.#owner.releasePointerCapture?.(this.pointerId);
93
+ this.#owner.releasePointerCapture?.(this.#pointerId);
90
94
  } catch {
91
95
  }
92
- this.#handlers.end?.();
96
+ this.#handlers.end?.(kind);
97
+ }
98
+ #owns(event) {
99
+ return this.#active && event.pointerId === this.#pointerId;
93
100
  }
94
101
  #onMove = (event) => {
95
- if (this.owns(event)) this.#handlers.move(event);
102
+ if (this.#owns(event)) this.#handlers.move(event);
96
103
  };
97
104
  #onEndEvent = (event) => {
98
- if (this.owns(event)) this.end();
99
- };
100
- #onLostCapture = (event) => {
101
- const pointerId = event.pointerId;
102
- if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
103
- this.end();
105
+ if (this.#owns(event)) this.#finish(event.type === "pointerup" ? "up" : "cancel");
104
106
  };
105
107
  };
106
108
 
@@ -232,7 +234,7 @@ var END_PROPERTY = "--stimeo--range-slider-end";
232
234
  var DEFAULT_MIN = 0;
233
235
  var DEFAULT_MAX = 100;
234
236
  var RangeSliderController = class extends Controller {
235
- static targets = ["track", "startThumb", "endThumb"];
237
+ static targets = ["track", "startThumb", "endThumb", "startField", "endField"];
236
238
  static values = {
237
239
  min: { type: Number, default: DEFAULT_MIN },
238
240
  max: { type: Number, default: DEFAULT_MAX },
@@ -242,9 +244,14 @@ var RangeSliderController = class extends Controller {
242
244
  logicalTrack: { type: Boolean, default: false }
243
245
  };
244
246
  static actions = ["onKeydown", "onPointerDown"];
245
- static events = ["change"];
247
+ static events = ["change", "reconcile"];
246
248
  /** One initiating pointer owns each live drag and its stable target snapshot. */
247
249
  #drag = null;
250
+ /**
251
+ * The pair last published: taken on connect, then moved by each user commit
252
+ * and by each repaint that reports a page-driven move.
253
+ */
254
+ #settled = { start: DEFAULT_MIN, end: DEFAULT_MAX };
248
255
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
249
256
  get #mirrored() {
250
257
  return this.logicalTrackValue && isRtl(this.element);
@@ -254,11 +261,14 @@ var RangeSliderController = class extends Controller {
254
261
  * pass Stimulus delivers before `connect()`.
255
262
  */
256
263
  #repaint = new MicrotaskCoalescer(() => this.#render());
264
+ /**
265
+ * Renders the normalized, ordered pair without writing it back, and takes it
266
+ * as the baseline, so connecting reports nothing.
267
+ */
257
268
  connect() {
258
269
  this.#repaint.activate();
259
- const range = this.#effectiveRange;
260
- const pair = this.#currentPair(range);
261
- this.#commit(pair.start, pair.end, null, false);
270
+ this.#settled = this.#currentPair(this.#effectiveRange);
271
+ this.#render();
262
272
  }
263
273
  /** Cancels any active pointer drag so document listeners never leak. */
264
274
  disconnect() {
@@ -313,6 +323,14 @@ var RangeSliderController = class extends Controller {
313
323
  }
314
324
  this.#repaint.schedule();
315
325
  }
326
+ /** Fills a start field inserted or replaced at runtime on the next repaint. */
327
+ startFieldTargetConnected() {
328
+ this.#repaint.schedule();
329
+ }
330
+ /** Fills an end field inserted or replaced at runtime on the next repaint. */
331
+ endFieldTargetConnected() {
332
+ this.#repaint.schedule();
333
+ }
316
334
  /** Drops a stale end-thumb reference without orphaning the track gesture. */
317
335
  endThumbTargetDisconnected(thumb) {
318
336
  if (this.#drag?.kind === "end" && this.#drag.thumb === thumb) this.#drag.thumb = null;
@@ -439,44 +457,72 @@ var RangeSliderController = class extends Controller {
439
457
  #moveThumb(kind, raw) {
440
458
  const pair = this.#currentPair(this.#effectiveRange);
441
459
  if (kind === "start") {
442
- this.#commit(raw, pair.end, "start", true);
460
+ this.#commit(raw, pair.end, "start");
443
461
  } else {
444
- this.#commit(pair.start, raw, "end", true);
462
+ this.#commit(pair.start, raw, "end");
445
463
  }
446
464
  }
447
465
  /**
448
- * Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and
449
- * reflects it onto the thumbs' ARIA attributes and the range custom
450
- * properties. Dispatches `stimeo--range-slider:change` only when a
451
- * user-driven update changes the normalized pair, with
452
- * `{ start: number, end: number }` in `detail`.
466
+ * Clamps and snaps the pair a user's move of the `moving` thumb produced,
467
+ * stops that thumb at its partner, stores the pair, and reflects it onto the
468
+ * thumbs' ARIA attributes and the range custom properties. The pair is
469
+ * reported as `change` when it differs from the pair last published — not from
470
+ * the pair the move started from — so a page write no repaint has published
471
+ * yet is reported once, as this change, and a move that ends on the pair last
472
+ * published reports nothing. The baseline moves before any report goes out,
473
+ * so the repaint the Value writes schedule — and any move a subscriber makes
474
+ * while the report is dispatched — is measured from this pair.
453
475
  */
454
- #commit(start, end, moving, notify) {
476
+ #commit(start, end, moving) {
455
477
  const range = this.#effectiveRange;
456
- const previous = this.#currentPair(range);
457
478
  let nextStart = snapSteppedValue(start, range);
458
479
  let nextEnd = snapSteppedValue(end, range);
459
480
  if (nextStart > nextEnd) {
460
481
  if (moving === "start") nextStart = nextEnd;
461
- else if (moving === "end") nextEnd = nextStart;
462
- else [nextStart, nextEnd] = [nextEnd, nextStart];
482
+ else nextEnd = nextStart;
463
483
  }
484
+ const pair = { start: nextStart, end: nextEnd };
485
+ const reported = this.#settled;
486
+ this.#settled = pair;
464
487
  if (!Object.is(this.startValue, nextStart)) this.startValue = nextStart;
465
488
  if (!Object.is(this.endValue, nextEnd)) this.endValue = nextEnd;
466
- const pair = { start: nextStart, end: nextEnd };
467
489
  this.#renderPair(pair, range);
468
- if (notify && (nextStart !== previous.start || nextEnd !== previous.end)) {
490
+ this.#mirrorFields(pair, true);
491
+ if (nextStart !== reported.start || nextEnd !== reported.end) {
469
492
  this.dispatch("change", { detail: { start: nextStart, end: nextEnd } });
470
493
  }
471
494
  }
472
495
  /**
473
- * Reflects morph-supplied Values without writing them back or dispatching.
496
+ * Reflects the Values the page supplied without writing them back. A published
497
+ * pair that moved from the last one is reported once as `reconcile`; the
498
+ * fields follow without a native `change`.
474
499
  *
475
500
  * @stimeoRenderRoot
476
501
  */
477
502
  #render() {
478
503
  const range = this.#effectiveRange;
479
- this.#renderPair(this.#currentPair(range), range);
504
+ const pair = this.#currentPair(range);
505
+ this.#renderPair(pair, range);
506
+ this.#mirrorFields(pair, false);
507
+ const settled = this.#settled;
508
+ if (pair.start === settled.start && pair.end === settled.end) return;
509
+ this.#settled = pair;
510
+ this.dispatch("reconcile", { detail: { start: pair.start, end: pair.end } });
511
+ }
512
+ /**
513
+ * Mirrors the pair into the optional form fields, reporting only a user's
514
+ * move. Each end is compared on its own, so dragging one thumb never reports
515
+ * a commit from the field the other thumb owns.
516
+ */
517
+ #mirrorFields(pair, notify) {
518
+ if (this.hasStartFieldTarget) {
519
+ const field = this.startFieldTarget;
520
+ if (writeField(field, String(pair.start)) && notify) commitField(field);
521
+ }
522
+ if (this.hasEndFieldTarget) {
523
+ const field = this.endFieldTarget;
524
+ if (writeField(field, String(pair.end)) && notify) commitField(field);
525
+ }
480
526
  }
481
527
  /** Reflects one normalized pair onto both thumbs and CSS properties. */
482
528
  #renderPair(pair, range) {
@@ -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) => {
@@ -139,13 +139,65 @@ var LayoutObserver = class {
139
139
  }
140
140
  };
141
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));
190
+ }
191
+ };
192
+
142
193
  // src/controllers/read_more_controller.ts
143
194
  var ReadMoreController = class extends Controller {
144
- static targets = ["content", "trigger"];
195
+ static targets = ["content", "trigger", "expandedLabel", "collapsedLabel"];
145
196
  static values = {
146
197
  collapsed: { type: Boolean, default: true }
147
198
  };
148
199
  static actions = ["toggle"];
200
+ static events = ["close", "open"];
149
201
  #connected = false;
150
202
  #collapsed = true;
151
203
  #observedContent = null;
@@ -154,6 +206,11 @@ var ReadMoreController = class extends Controller {
154
206
  if (this.#connected) this.#evaluateOverflow();
155
207
  };
156
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
+ });
157
214
  /** Holds the trigger's hide back while it has focus; re-evaluates on blur. */
158
215
  #deferredHide = new BlurDeferral(() => {
159
216
  this.#update();
@@ -181,12 +238,21 @@ var ReadMoreController = class extends Controller {
181
238
  this.#deferredHide.release(trigger);
182
239
  this.#syncTargets();
183
240
  }
241
+ expandedLabelTargetConnected() {
242
+ this.#syncTargets();
243
+ }
244
+ collapsedLabelTargetConnected() {
245
+ this.#syncTargets();
246
+ }
184
247
  /** Toggles between the collapsed (clamped) and expanded states. */
185
- toggle() {
248
+ toggle(event) {
186
249
  if (!this.#connected) return;
187
250
  this.#collapsed = !this.#collapsed;
188
251
  this.#reflect();
189
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 });
190
256
  }
191
257
  #initialCollapsed() {
192
258
  if (this.hasContentTarget) {
@@ -202,6 +268,7 @@ var ReadMoreController = class extends Controller {
202
268
  }
203
269
  if (this.hasTriggerTarget) {
204
270
  this.triggerTarget.setAttribute("aria-expanded", this.#collapsed ? "false" : "true");
271
+ this.#labels.reflect(this.triggerTarget, !this.#collapsed);
205
272
  }
206
273
  }
207
274
  #syncTargets() {
@@ -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() {
@@ -518,7 +518,11 @@ var ScrollAreaController = class extends Controller {
518
518
  this.#overflowing = false;
519
519
  this.#lastEdge = null;
520
520
  }
521
- /** Runs the full structural and positional measurement pass. */
521
+ /**
522
+ * Runs the full structural and positional measurement pass.
523
+ *
524
+ * @stimeoRenderRoot
525
+ */
522
526
  #refresh() {
523
527
  const viewport = this.#viewport;
524
528
  if (!viewport) return;
@@ -332,7 +332,6 @@ var ScrollVisibilityController = class extends Controller {
332
332
  this.element.setAttribute("data-state", next ? "visible" : "hidden");
333
333
  if (notify) this.dispatch("change", { detail: { visible: next } });
334
334
  }
335
- /** Resolves the scroll source from `root` (falling back to the window). */
336
335
  /**
337
336
  * The focus owner inside the target, or `null` when focus is elsewhere.
338
337
  *
@@ -422,6 +422,10 @@ var ScrollspyController = class extends Controller {
422
422
  this.#scrollSource?.removeEventListener("scroll", this.#onScroll);
423
423
  this.#scrollSource = null;
424
424
  }
425
+ /**
426
+ * @stimeoRuntimeOnly `rootMargin` wires the observer this call installs; the active section it
427
+ * republishes is the one already recorded.
428
+ */
425
429
  #initializeObserver() {
426
430
  this.#watcher.stop();
427
431
  this.#intersectionStates.clear();
@@ -467,6 +471,8 @@ var ScrollspyController = class extends Controller {
467
471
  * threshold) against a freshly computed trigger line mixes two moments in
468
472
  * time, which would make the result depend on how the reader arrived at a
469
473
  * position — a smooth scroll and an instant jump to the same offset disagree.
474
+ *
475
+ * @stimeoRenderRoot
470
476
  */
471
477
  #evaluateActiveSection() {
472
478
  const rootEl = this.#scrollRoot();