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
data/dist/cable/index.js CHANGED
@@ -164,6 +164,18 @@ var SafeInterval = class extends TimerRegistry {
164
164
  window.clearInterval(id);
165
165
  }
166
166
  };
167
+
168
+ // src/utils/transient_hooks.ts
169
+ var TransientHooks = class {
170
+ #attributes;
171
+ constructor(options) {
172
+ this.#attributes = options.attributes;
173
+ }
174
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
175
+ reset(element) {
176
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
177
+ }
178
+ };
167
179
  function parseSubscriptionParams(raw) {
168
180
  try {
169
181
  const parsed = JSON.parse(raw);
@@ -270,6 +282,7 @@ function openSharedSubscription(consumer, channel) {
270
282
  }
271
283
 
272
284
  // src/cable/live_counter_controller.ts
285
+ var TRANSIENT = new TransientHooks({ attributes: ["data-live-counter-rejected"] });
273
286
  var DISABLED_MARKER = "data-live-counter-disabled";
274
287
  var LiveCounterController = class _LiveCounterController extends Controller {
275
288
  static targets = ["value", "trigger"];
@@ -323,9 +336,12 @@ var LiveCounterController = class _LiveCounterController extends Controller {
323
336
  * The rejected hook is transient server state: a Turbo cache snapshot must not
324
337
  * resurrect it — the fresh subscription re-decides it. An outstanding guess
325
338
  * belongs to the identifier that is going away, so it is dropped with it.
339
+ *
340
+ * @stimeoRuntimeOnly `channel` decides whether this call opens a subscription at all; the
341
+ * rejected hook it clears does not depend on it.
326
342
  */
327
343
  #subscribe() {
328
- this.element.removeAttribute("data-live-counter-rejected");
344
+ TRANSIENT.reset(this.element);
329
345
  this.#outstanding.length = 0;
330
346
  if (this.channelValue) {
331
347
  this.#subscription = this.#open();
@@ -340,6 +356,9 @@ var LiveCounterController = class _LiveCounterController extends Controller {
340
356
  * half-wired: no gate reflected, and a trigger that looks live. The gate reads
341
357
  * the declaration, so a null subscription keeps it shut and the display never
342
358
  * moves past what the server can receive.
359
+ *
360
+ * @stimeoRuntimeOnly `channel` and its params choose the one subscription this call opens; `id`
361
+ * is read by the handlers it wires.
343
362
  */
344
363
  #open() {
345
364
  try {
@@ -388,7 +407,7 @@ var LiveCounterController = class _LiveCounterController extends Controller {
388
407
  this.#focusedTriggers.releaseAll();
389
408
  this.#subscription?.unsubscribe();
390
409
  this.#subscription = null;
391
- this.element.removeAttribute("data-live-counter-rejected");
410
+ TRANSIENT.reset(this.element);
392
411
  for (const trigger of this.triggerTargets) this.#release(trigger);
393
412
  }
394
413
  /** Late-added triggers (e.g. via a Turbo Stream) pick up the current gate. */
@@ -467,6 +486,8 @@ var LiveCounterController = class _LiveCounterController extends Controller {
467
486
  * authored-disabled trigger ("disabled until valid", say) is never
468
487
  * re-enabled by the gate. A marked disabled restored from a Turbo cache
469
488
  * snapshot is recognized as ours and lifted once the gate opens.
489
+ *
490
+ * @stimeoRenderRoot
470
491
  */
471
492
  #syncTrigger(trigger) {
472
493
  if (this.#ready) {
@@ -510,6 +531,9 @@ var LiveCounterController = class _LiveCounterController extends Controller {
510
531
  * Settles the display on a count the server stated, announcing what it settled
511
532
  * on. Announcing the optimistic bump instead would read a guess out and then
512
533
  * correct it, so the announcement rides this path only.
534
+ *
535
+ * @stimeoRuntimeOnly The count shown is the value received; `announceText` words the one
536
+ * announcement of this change.
513
537
  */
514
538
  #reconcile(count) {
515
539
  if (this.#write(count)) this.#announce(count);
@@ -530,6 +554,60 @@ var LiveCounterController = class _LiveCounterController extends Controller {
530
554
  }, _LiveCounterController.#announceDelay);
531
555
  }
532
556
  };
557
+
558
+ // src/utils/keyed_timers.ts
559
+ var KeyedTimers = class {
560
+ #timers = new SafeTimeout();
561
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
562
+ #pending = /* @__PURE__ */ new Map();
563
+ /**
564
+ * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
565
+ * pending. The entry is dropped before the callback runs, so the callback sees
566
+ * the key unarmed and may arm it again for the next round.
567
+ */
568
+ set(key, callback, delay) {
569
+ this.clear(key);
570
+ const id = this.#timers.set(() => {
571
+ this.#pending.delete(key);
572
+ callback();
573
+ }, delay);
574
+ this.#pending.set(key, id);
575
+ }
576
+ /**
577
+ * Cancels `key`'s pending timer, if it has one.
578
+ *
579
+ * A timer id is a positive integer, so `-1` stands for "nothing pending" and
580
+ * the registry ignores an id it does not own — the unarmed case needs no
581
+ * branch of its own, and no other key's timer can be reached from here.
582
+ */
583
+ clear(key) {
584
+ this.#timers.clear(this.#pending.get(key) ?? -1);
585
+ this.#pending.delete(key);
586
+ }
587
+ /**
588
+ * Cancels every pending timer and forgets every key. Call this from a
589
+ * controller's `disconnect()` so no timer, and no entry, outlives the element.
590
+ */
591
+ clearAll() {
592
+ this.#timers.clearAll();
593
+ this.#pending.clear();
594
+ }
595
+ /** Whether `key` has a timer pending. */
596
+ has(key) {
597
+ return this.#pending.has(key);
598
+ }
599
+ };
600
+
601
+ // src/utils/template_row.ts
602
+ function cloneTemplateRoot(template) {
603
+ const root = template.content.firstElementChild;
604
+ return root instanceof HTMLElement ? root.cloneNode(true) : null;
605
+ }
606
+
607
+ // src/cable/presence_controller.ts
608
+ var TRANSIENT2 = new TransientHooks({
609
+ attributes: ["data-present", "data-present-count", "data-presence-rejected"]
610
+ });
533
611
  var BEACON_THROTTLE_MS = 2e3;
534
612
  var DEFAULT_HEARTBEAT_MS = 15e3;
535
613
  var DEFAULT_TIMEOUT_MS = 4e4;
@@ -554,6 +632,8 @@ var PresenceController = class _PresenceController extends Controller {
554
632
  #subscription = null;
555
633
  /** Present peers keyed by id (insertion order = join order). */
556
634
  #peers = /* @__PURE__ */ new Map();
635
+ /** Each peer's auto-expiry timer, restarted by every beacon from that peer. */
636
+ #expiry = new KeyedTimers();
557
637
  #timers = new SafeTimeout();
558
638
  #intervals = new SafeInterval();
559
639
  /** Epoch ms of the last outgoing beacon, for the convergence throttle. */
@@ -622,7 +702,6 @@ var PresenceController = class _PresenceController extends Controller {
622
702
  }
623
703
  connect() {
624
704
  this.#reset();
625
- this.element.removeAttribute("data-presence-rejected");
626
705
  if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
627
706
  if (!this.channelValue) return;
628
707
  this.#claimSpeaker();
@@ -648,7 +727,6 @@ var PresenceController = class _PresenceController extends Controller {
648
727
  this.#subscription = null;
649
728
  this.#intervals.clearAll();
650
729
  this.#reset();
651
- this.element.removeAttribute("data-presence-rejected");
652
730
  }
653
731
  /**
654
732
  * Registers this element as one of the voices for its peer, so a sibling on the
@@ -728,6 +806,9 @@ ${this.idValue}`;
728
806
  * Tracks a broadcast beacon: upserts the peer (restarting its expiry timer),
729
807
  * removes it on a `leaving` notice, and re-announces this client when the
730
808
  * peer was unknown (roster convergence — see the class doc).
809
+ *
810
+ * @stimeoRuntimeOnly `id` filters this client's own echo, `timeout` arms one peer's expiry,
811
+ * `name` answers one beacon and the texts word one announcement.
731
812
  */
732
813
  #onReceived(data) {
733
814
  const beacon = data;
@@ -739,9 +820,8 @@ ${this.idValue}`;
739
820
  }
740
821
  const name = typeof beacon?.name === "string" ? beacon.name : "";
741
822
  const existing = this.#peers.get(id);
742
- if (existing !== void 0) this.#timers.clear(existing.timer);
743
- const timer = this.#timers.set(() => this.#drop(id), this.#timeout);
744
- this.#peers.set(id, { name, timer });
823
+ this.#expiry.set(id, () => this.#drop(id), this.#timeout);
824
+ this.#peers.set(id, { name });
745
825
  if (existing === void 0) {
746
826
  this.#appendClone(id, name);
747
827
  this.#render();
@@ -753,18 +833,24 @@ ${this.idValue}`;
753
833
  this.#render();
754
834
  }
755
835
  }
756
- /** Removes a peer (expiry or graceful leave) and reflects the change. */
836
+ /**
837
+ * Removes a peer (expiry or graceful leave) and reflects the change.
838
+ *
839
+ * @stimeoRuntimeOnly `announceLeaveText` words the one announcement this departure makes.
840
+ */
757
841
  #drop(id) {
758
842
  const peer = this.#peers.get(id);
759
843
  if (peer === void 0) return;
760
- this.#timers.clear(peer.timer);
844
+ this.#expiry.clear(id);
761
845
  this.#peers.delete(id);
762
846
  this.#removeClone(id);
763
847
  this.#render();
764
848
  this.#announce(this.announceLeaveTextValue, peer.name);
765
849
  this.dispatch("leave", { detail: { id } });
766
850
  }
767
- /** Reflects the roster onto the hooks + count target and emits `change`. */
851
+ /**
852
+ * Reflects the roster onto the hooks + count target and emits `change`.
853
+ */
768
854
  #render() {
769
855
  const users = [...this.#peers.entries()].map(([id, peer]) => ({ id, name: peer.name }));
770
856
  this.element.setAttribute("data-present", users.length > 0 ? "true" : "false");
@@ -803,8 +889,8 @@ ${this.idValue}`;
803
889
  /** Appends one template clone for a newly present peer (list + template only). */
804
890
  #appendClone(id, name) {
805
891
  if (!this.hasListTarget || !this.hasTemplateTarget) return;
806
- const root = this.templateTarget.content.firstElementChild?.cloneNode(true);
807
- if (!(root instanceof Element)) return;
892
+ const root = cloneTemplateRoot(this.templateTarget);
893
+ if (!root) return;
808
894
  root.setAttribute("data-presence-id", id);
809
895
  this.#fillName(root, name);
810
896
  this.listTarget.appendChild(root);
@@ -834,12 +920,12 @@ ${this.idValue}`;
834
920
  /** Clears the transient roster state (connect reset + disconnect teardown). */
835
921
  #reset() {
836
922
  this.#timers.clearAll();
923
+ this.#expiry.clearAll();
837
924
  this.#pendingBeacon = null;
838
925
  this.#announceId = null;
839
926
  this.#peers.clear();
840
927
  this.#lastBeaconAt = 0;
841
- this.element.removeAttribute("data-present");
842
- this.element.removeAttribute("data-present-count");
928
+ TRANSIENT2.reset(this.element);
843
929
  if (this.hasCountTarget) this.countTarget.textContent = "";
844
930
  if (this.hasListTarget) {
845
931
  for (const child of this.listTarget.querySelectorAll("[data-presence-id]")) {
@@ -848,6 +934,9 @@ ${this.idValue}`;
848
934
  }
849
935
  }
850
936
  };
937
+ var TRANSIENT3 = new TransientHooks({
938
+ attributes: ["data-typing", "data-typing-indicator-rejected"]
939
+ });
851
940
  var DEFAULT_TIMEOUT = 3e3;
852
941
  var DEFAULT_THROTTLE = 2e3;
853
942
  var fillTokens = (template, values) => template.replace(/%\{(name|names|count)\}/g, (match, token) => values[token] ?? match);
@@ -871,8 +960,10 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
871
960
  /** Identifier parameters parsed once from their declaration, never in the hot path. */
872
961
  #params = {};
873
962
  #subscription = null;
874
- /** Names currently typing (other clients), each with its auto-clear timer id. */
875
- #typers = /* @__PURE__ */ new Map();
963
+ /** Names currently typing (other clients), in the order their first signal arrived. */
964
+ #typers = /* @__PURE__ */ new Set();
965
+ /** Each typer's auto-clear timer, restarted by every further signal from that name. */
966
+ #expiry = new KeyedTimers();
876
967
  #timers = new SafeTimeout();
877
968
  /** Epoch ms of the last broadcast, for leading-edge throttling. */
878
969
  #lastSentAt = 0;
@@ -899,8 +990,7 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
899
990
  if (this.#typers.size > 0) this.#paint();
900
991
  }
901
992
  connect() {
902
- this.element.removeAttribute("data-typing");
903
- this.element.removeAttribute("data-typing-indicator-rejected");
993
+ TRANSIENT3.reset(this.element);
904
994
  if (this.hasStatusTarget) this.statusTarget.textContent = "";
905
995
  this.element.addEventListener("input", this.#onInput);
906
996
  if (this.channelValue) {
@@ -922,10 +1012,10 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
922
1012
  this.#subscription?.unsubscribe();
923
1013
  this.#subscription = null;
924
1014
  this.#timers.clearAll();
1015
+ this.#expiry.clearAll();
925
1016
  this.#announceId = null;
926
1017
  this.#typers.clear();
927
- this.element.removeAttribute("data-typing");
928
- this.element.removeAttribute("data-typing-indicator-rejected");
1018
+ TRANSIENT3.reset(this.element);
929
1019
  if (this.hasStatusTarget) this.statusTarget.textContent = "";
930
1020
  this.#lastSentAt = 0;
931
1021
  }
@@ -941,17 +1031,16 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
941
1031
  * Tracks a broadcast typer. The own echo is dropped (same `name`); every
942
1032
  * further signal from a name restarts its auto-clear timer, so the indicator
943
1033
  * survives continuous typing and clears `timeout` ms after the last signal.
1034
+ *
1035
+ * @stimeoRuntimeOnly `name` filters this client's own echo and `timeout` arms one typer's expiry;
1036
+ * the typers shown come from what is received.
944
1037
  */
945
1038
  #onReceived(data) {
946
1039
  const name = data?.name;
947
1040
  if (typeof name !== "string" || name === "" || name === this.nameValue) return;
948
- const existing = this.#typers.get(name);
949
- if (existing !== void 0) this.#timers.clear(existing);
950
- const added = existing === void 0;
951
- this.#typers.set(
952
- name,
953
- this.#timers.set(() => this.#untrack(name), this.#timeout)
954
- );
1041
+ const added = !this.#typers.has(name);
1042
+ this.#typers.add(name);
1043
+ this.#expiry.set(name, () => this.#untrack(name), this.#timeout);
955
1044
  if (added) this.#render();
956
1045
  }
957
1046
  /**
@@ -981,7 +1070,9 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
981
1070
  this.#typers.delete(name);
982
1071
  this.#render();
983
1072
  }
984
- /** Reflects the typer set onto the display, announces it, and emits `change`. */
1073
+ /**
1074
+ * Reflects the typer set onto the display, announces it, and emits `change`.
1075
+ */
985
1076
  #render() {
986
1077
  const names = this.#paint();
987
1078
  this.#announce(names);
@@ -995,6 +1086,9 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
995
1086
  * a reader for, and the visible copy already clears. Wording comes from the
996
1087
  * consumer, and {@link announce} drops an empty message, so an undeclared template
997
1088
  * announces nothing at all.
1089
+ *
1090
+ * @stimeoRuntimeOnly The wording Values are read to compose one announcement; the
1091
+ * visible typer list is rendered from the set, not from them.
998
1092
  */
999
1093
  #announce(names) {
1000
1094
  if (this.#announceId !== null) this.#timers.clear(this.#announceId);
@@ -1011,7 +1105,7 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
1011
1105
  }
1012
1106
  /** Writes the current typer set onto the hook and the status slot. */
1013
1107
  #paint() {
1014
- const names = [...this.#typers.keys()];
1108
+ const names = [...this.#typers];
1015
1109
  this.element.setAttribute("data-typing", names.length > 0 ? "true" : "false");
1016
1110
  if (this.hasStatusTarget) {
1017
1111
  this.statusTarget.textContent = this.#message(names);
@@ -8,36 +8,125 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/state_reason.ts
12
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
13
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
14
+ function stateReasonFor(event) {
15
+ if (!event) return "api";
16
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
17
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
18
+ return "user";
19
+ }
20
+
21
+ // src/utils/state_regions.ts
22
+ var StateRegions = class {
23
+ #whenTrue;
24
+ #whenFalse;
25
+ #taken = /* @__PURE__ */ new WeakSet();
26
+ constructor(options) {
27
+ this.#whenTrue = options.whenTrue;
28
+ this.#whenFalse = options.whenFalse ?? null;
29
+ }
30
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
31
+ reflect(host, isTrue) {
32
+ const shown = this.#inside(host, this.#whenTrue());
33
+ if (!this.#whenFalse) {
34
+ for (const region of shown) this.#write(region, !isTrue);
35
+ return;
36
+ }
37
+ const hiddenSide = this.#inside(host, this.#whenFalse());
38
+ if (shown.length === 0 || hiddenSide.length === 0) {
39
+ for (const region of shown) this.#give(region);
40
+ for (const region of hiddenSide) this.#give(region);
41
+ return;
42
+ }
43
+ for (const region of shown) this.#write(region, !isTrue);
44
+ for (const region of hiddenSide) this.#write(region, isTrue);
45
+ }
46
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
47
+ #write(region, hidden) {
48
+ if (hidden) this.#taken.add(region);
49
+ else this.#taken.delete(region);
50
+ if (region.hidden !== hidden) region.hidden = hidden;
51
+ }
52
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
53
+ #give(region) {
54
+ if (this.#taken.delete(region)) region.hidden = false;
55
+ }
56
+ /** The declared regions that sit within `host`, which may be the host itself. */
57
+ #inside(host, regions) {
58
+ return regions.filter((region) => host.contains(region));
59
+ }
60
+ };
61
+
11
62
  // src/controllers/accordion_controller.ts
12
63
  var AccordionController = class extends Controller {
13
- static targets = ["trigger", "panel"];
64
+ static targets = ["trigger", "panel", "expandedLabel", "collapsedLabel"];
14
65
  static actions = ["collapseAll", "expandAll", "onKeydown", "toggle"];
66
+ static events = ["close", "open"];
67
+ /** Owns `hidden` on the label pair a header declares, narrowed to that header. */
68
+ #labels = new StateRegions({
69
+ whenTrue: () => this.expandedLabelTargets,
70
+ whenFalse: () => this.collapsedLabelTargets
71
+ });
72
+ /** Settles the label pair of a header as it joins the group. */
73
+ triggerTargetConnected(trigger) {
74
+ this.#reflectLabels(trigger);
75
+ }
76
+ /** Settles the pair of the header an arriving expanded half belongs to. */
77
+ expandedLabelTargetConnected(label) {
78
+ this.#settleLabelHost(label);
79
+ }
80
+ /** Settles the pair of the header an arriving collapsed half belongs to. */
81
+ collapsedLabelTargetConnected(label) {
82
+ this.#settleLabelHost(label);
83
+ }
84
+ /** Reflects the pair of the header a label half sits in, where a header holds it. */
85
+ #settleLabelHost(label) {
86
+ const trigger = this.triggerTargets.find((candidate) => candidate.contains(label));
87
+ if (trigger) this.#reflectLabels(trigger);
88
+ }
15
89
  /** Toggles the panel controlled by the activated header. */
16
90
  toggle(event) {
17
91
  const trigger = event.currentTarget;
18
92
  const panel = this.#panelFor(trigger);
19
93
  if (!panel) return;
20
- this.#setExpanded(trigger, panel, trigger.getAttribute("aria-expanded") !== "true");
94
+ this.#setExpanded(
95
+ trigger,
96
+ panel,
97
+ trigger.getAttribute("aria-expanded") !== "true",
98
+ stateReasonFor(event)
99
+ );
21
100
  }
22
101
  /** Opens every panel. Bound via `data-action` on an "expand all" control. */
23
- expandAll() {
24
- this.#setAll(true);
102
+ expandAll(event) {
103
+ this.#setAll(true, stateReasonFor(event));
25
104
  }
26
105
  /** Closes every panel. Bound via `data-action` on a "collapse all" control. */
27
- collapseAll() {
28
- this.#setAll(false);
106
+ collapseAll(event) {
107
+ this.#setAll(false, stateReasonFor(event));
29
108
  }
30
109
  /** Drives every header/panel pair to the same expanded state. */
31
- #setAll(open) {
110
+ #setAll(open, reason) {
32
111
  for (const trigger of this.triggerTargets) {
33
112
  const panel = this.#panelFor(trigger);
34
- if (panel) this.#setExpanded(trigger, panel, open);
113
+ if (panel) this.#setExpanded(trigger, panel, open, reason);
35
114
  }
36
115
  }
37
116
  /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */
38
- #setExpanded(trigger, panel, open) {
117
+ #setExpanded(trigger, panel, open, reason) {
118
+ const was = trigger.getAttribute("aria-expanded") === "true";
39
119
  trigger.setAttribute("aria-expanded", open ? "true" : "false");
40
120
  panel.hidden = !open;
121
+ this.#labels.reflect(trigger, open);
122
+ if (was === open) return;
123
+ const detail = { reason, index: this.triggerTargets.indexOf(trigger), trigger, panel };
124
+ if (open) this.dispatch("open", { detail, cancelable: false });
125
+ else this.dispatch("close", { detail, cancelable: false });
126
+ }
127
+ /** Shows the half of a header's label pair that belongs to its `aria-expanded`. */
128
+ #reflectLabels(trigger) {
129
+ this.#labels.reflect(trigger, trigger.getAttribute("aria-expanded") === "true");
41
130
  }
42
131
  /**
43
132
  * Moves focus between headers per the APG keyboard model, skipping any header