stimeo-ui 0.16.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,19 +1,14 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/textarea_autosize_controller.ts
4
-
5
- // src/utils/layout_observer.ts
6
3
  var LayoutObserver = class {
7
4
  #callback;
8
5
  #resizeObserverFactory;
9
6
  #resizeObserver = null;
10
7
  #observingViewport = false;
11
8
  #loadContainer = null;
12
- /** Stable bound handler so add/removeEventListener target the same reference. */
13
9
  #handleViewportResize = () => {
14
10
  this.#callback();
15
11
  };
16
- /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
17
12
  #handleDescendantLoad = () => {
18
13
  this.#callback();
19
14
  };
@@ -21,11 +16,6 @@ var LayoutObserver = class {
21
16
  this.#callback = callback;
22
17
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
23
18
  }
24
- /**
25
- * Starts observing an element's size. Repeated calls observe additional
26
- * elements through the same shared observer. No-ops when no
27
- * `ResizeObserver` implementation is available.
28
- */
29
19
  observe(element) {
30
20
  if (!this.#resizeObserverFactory) return;
31
21
  if (!this.#resizeObserver) {
@@ -35,47 +25,28 @@ var LayoutObserver = class {
35
25
  }
36
26
  this.#resizeObserver.observe(element);
37
27
  }
38
- /** Stops observing a single element while leaving any others in place. */
39
28
  unobserve(element) {
40
29
  this.#resizeObserver?.unobserve(element);
41
30
  }
42
- /** Starts observing viewport resizes. Idempotent: the listener is added once. */
43
31
  observeViewport() {
44
32
  if (this.#observingViewport) return;
45
33
  this.#observingViewport = true;
46
34
  window.addEventListener("resize", this.#handleViewportResize);
47
35
  }
48
- /** Stops observing viewport resizes without affecting element observation. */
49
36
  unobserveViewport() {
50
37
  if (!this.#observingViewport) return;
51
38
  this.#observingViewport = false;
52
39
  window.removeEventListener("resize", this.#handleViewportResize);
53
40
  }
54
- /**
55
- * Starts reporting a `load` from anywhere inside `container` — an image or a
56
- * frame settling changes the box it sits in, and it measures as zero high until
57
- * then. `load` does not bubble, so the subscription is a capture-phase listener
58
- * on the container itself and nothing the caller spells.
59
- *
60
- * **One container at a time.** A further call moves the observation, so a widget
61
- * whose content element is swapped at runtime releases the element it let go by
62
- * naming the new one — there is no second place for the release to drift from.
63
- */
64
41
  observeDescendantLoads(container) {
65
42
  this.unobserveDescendantLoads();
66
43
  this.#loadContainer = container;
67
44
  container.addEventListener("load", this.#handleDescendantLoad, true);
68
45
  }
69
- /** Stops reporting descendant loads without affecting element or viewport observation. */
70
46
  unobserveDescendantLoads() {
71
47
  this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
72
48
  this.#loadContainer = null;
73
49
  }
74
- /**
75
- * Releases every observation: disconnects the {@link ResizeObserver} and removes
76
- * the viewport and descendant-load listeners. Safe to call multiple times. Call
77
- * this from a controller's `disconnect()`.
78
- */
79
50
  disconnect() {
80
51
  this.#resizeObserver?.disconnect();
81
52
  this.#resizeObserver = null;
@@ -84,16 +55,64 @@ var LayoutObserver = class {
84
55
  }
85
56
  };
86
57
 
87
- // src/controllers/textarea_autosize_controller.ts
58
+ var NUMBER_BOUNDS = {
59
+ nonNegative: { finite: true, min: 0 }};
60
+ function matchesNumberBounds(value, bounds) {
61
+ if (!Number.isFinite(value)) {
62
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
63
+ if (direction === null) return false;
64
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
65
+ }
66
+ if (bounds.min !== void 0 && value < bounds.min) return false;
67
+ if (bounds.max !== void 0 && value > bounds.max) return false;
68
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
69
+ if (bounds.integer && !Number.isInteger(value)) return false;
70
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
71
+ return true;
72
+ }
73
+
74
+ function readNumber(raw, fallback, bounds) {
75
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
76
+ }
77
+
78
+ var NumberValueReader = class {
79
+ #lastRejected = /* @__PURE__ */ new Map();
80
+ read(owner, name, raw, fallback, bounds) {
81
+ const resolved = readNumber(raw, fallback, bounds);
82
+ if (matchesNumberBounds(raw, bounds)) {
83
+ this.#lastRejected.delete(name);
84
+ return resolved;
85
+ }
86
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
87
+ const literal = owner.element.getAttribute(attribute);
88
+ if (literal === null) {
89
+ this.#lastRejected.delete(name);
90
+ return resolved;
91
+ }
92
+ if (this.#lastRejected.get(name) !== literal) {
93
+ this.#lastRejected.set(name, literal);
94
+ console.warn(
95
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
96
+ );
97
+ }
98
+ return resolved;
99
+ }
100
+ };
101
+
88
102
  function px(value) {
89
103
  const n = Number.parseFloat(value);
90
104
  return Number.isNaN(n) ? 0 : n;
91
105
  }
92
- var TextareaAutosizeController = class extends Controller {
106
+ var TextareaAutosizeController = class _TextareaAutosizeController extends Controller {
107
+ #numbers = new NumberValueReader();
93
108
  static values = {
94
109
  minRows: { type: Number, default: 1 },
95
110
  maxRows: { type: Number, default: 0 }
96
111
  };
112
+ static valueConstraints = {
113
+ minRows: { finite: true, min: 1 },
114
+ maxRows: NUMBER_BOUNDS.nonNegative
115
+ };
97
116
  static actions = ["resize"];
98
117
  static events = ["resize"];
99
118
  #lastHeight = -1;
@@ -101,12 +120,6 @@ var TextareaAutosizeController = class extends Controller {
101
120
  #started = false;
102
121
  #pending = false;
103
122
  #fonts = null;
104
- /**
105
- * Content triggers arriving while the box is collapsed (`display: none`
106
- * ancestors) are deferred: `scrollHeight` is 0 there, so measuring would
107
- * clamp the kept height to the `minRows` floor and dispatch a bogus
108
- * `resize`. The next report from a rendered box flushes the deferral.
109
- */
110
123
  #remeasure = () => {
111
124
  if (this.element.clientWidth === 0) {
112
125
  this.#pending = true;
@@ -114,16 +127,6 @@ var TextareaAutosizeController = class extends Controller {
114
127
  }
115
128
  this.resize();
116
129
  };
117
- /**
118
- * Re-measures when the content width changed (re-wrapping, including a
119
- * horizontal padding change under `border-box`), the applied inline height
120
- * was stripped (a Turbo morph syncing attributes from server HTML that has no
121
- * `style`), or a content trigger was deferred while the box was collapsed.
122
- * This instance's own height writes match none of these signals, so they
123
- * cannot re-enter; an authored inline height (e.g. a user dragging the native
124
- * resize handle) is left alone until the next content trigger. Reports from a
125
- * still-collapsed box are skipped (nothing is measurable there).
126
- */
127
130
  #layout = new LayoutObserver(() => {
128
131
  if (this.element.clientWidth === 0) return;
129
132
  const width = this.#contentWidth();
@@ -149,15 +152,12 @@ var TextareaAutosizeController = class extends Controller {
149
152
  this.#layout.disconnect();
150
153
  this.#unbindFonts();
151
154
  }
152
- /** Re-clamps when application code or a Turbo morph changes `minRows`. */
153
155
  minRowsValueChanged() {
154
156
  if (this.#started) this.#remeasure();
155
157
  }
156
- /** Re-clamps when application code or a Turbo morph changes `maxRows`. */
157
158
  maxRowsValueChanged() {
158
159
  if (this.#started) this.#remeasure();
159
160
  }
160
- /** Re-measures the content and applies the clamped height. */
161
161
  resize() {
162
162
  this.#pending = false;
163
163
  const el = this.element;
@@ -169,10 +169,10 @@ var TextareaAutosizeController = class extends Controller {
169
169
  el.style.height = "auto";
170
170
  const contentHeight = Math.max(0, el.scrollHeight - paddingV);
171
171
  const rows = Math.max(1, Math.round(contentHeight / lineHeight));
172
- let targetContent = Math.max(contentHeight, this.minRowsValue * lineHeight);
172
+ let targetContent = Math.max(contentHeight, this.#safeMinRows * lineHeight);
173
173
  let atMax = false;
174
- if (this.maxRowsValue > 0) {
175
- const maxContent = this.maxRowsValue * lineHeight;
174
+ if (this.#safeMaxRows > 0) {
175
+ const maxContent = this.#safeMaxRows * lineHeight;
176
176
  if (targetContent > maxContent) {
177
177
  targetContent = maxContent;
178
178
  atMax = true;
@@ -193,19 +193,16 @@ var TextareaAutosizeController = class extends Controller {
193
193
  this.dispatch("resize", { detail: { height, rows } });
194
194
  }
195
195
  }
196
- /** Content-box width — the wrapping input, unaffected by this instance's height writes. */
197
196
  #contentWidth() {
198
197
  const style = window.getComputedStyle(this.element);
199
198
  return Math.max(0, this.element.clientWidth - px(style.paddingLeft) - px(style.paddingRight));
200
199
  }
201
- /** Resolved line height, falling back to ~1.2× font-size when `normal`. */
202
200
  #lineHeight(style) {
203
201
  const lh = px(style.lineHeight);
204
202
  if (lh > 0) return lh;
205
203
  const fontSize = px(style.fontSize);
206
204
  return fontSize > 0 ? fontSize * 1.2 : 16;
207
205
  }
208
- /** Subscribes to font completion when the current engine exposes `document.fonts`. */
209
206
  #bindFonts() {
210
207
  const fonts = this.element.ownerDocument.fonts;
211
208
  if (!fonts) return;
@@ -213,14 +210,30 @@ var TextareaAutosizeController = class extends Controller {
213
210
  fonts.addEventListener("loadingdone", this.#remeasure);
214
211
  fonts.addEventListener("loadingerror", this.#remeasure);
215
212
  }
216
- /** Removes the font completion subscriptions. */
217
213
  #unbindFonts() {
218
214
  this.#fonts?.removeEventListener("loadingdone", this.#remeasure);
219
215
  this.#fonts?.removeEventListener("loadingerror", this.#remeasure);
220
216
  this.#fonts = null;
221
217
  }
218
+ get #safeMinRows() {
219
+ return this.#numbers.read(
220
+ this,
221
+ "minRows",
222
+ this.minRowsValue,
223
+ _TextareaAutosizeController.values.minRows.default,
224
+ _TextareaAutosizeController.valueConstraints.minRows
225
+ );
226
+ }
227
+ get #safeMaxRows() {
228
+ return this.#numbers.read(
229
+ this,
230
+ "maxRows",
231
+ this.maxRowsValue,
232
+ _TextareaAutosizeController.values.maxRows.default,
233
+ _TextareaAutosizeController.valueConstraints.maxRows
234
+ );
235
+ }
222
236
  };
223
237
 
224
238
  export { TextareaAutosizeController };
225
- //# sourceMappingURL=textarea_autosize_controller.js.map
226
239
  //# sourceMappingURL=textarea_autosize_controller.js.map
@@ -1,13 +1,31 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/theme_controller.ts
3
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
4
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
5
+ function stateReasonFor(event) {
6
+ if (!event) return "api";
7
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
8
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
9
+ return "user";
10
+ }
11
+
12
+ function actionSource(source) {
13
+ if (source instanceof HTMLElement) {
14
+ return { event: null, host: source, origin: source, reason: "api" };
15
+ }
16
+ const event = source ?? null;
17
+ return {
18
+ event,
19
+ host: event?.currentTarget instanceof Element ? event.currentTarget : null,
20
+ origin: event?.target instanceof Element ? event.target : null,
21
+ reason: stateReasonFor(event)
22
+ };
23
+ }
4
24
 
5
- // src/utils/logical_scroll.ts
6
25
  function isRtl(element) {
7
26
  return window.getComputedStyle(element).direction === "rtl";
8
27
  }
9
28
 
10
- // src/utils/arrow_step.ts
11
29
  function logicalArrowStep(key, element) {
12
30
  if (key === "ArrowDown") return 1;
13
31
  if (key === "ArrowUp") return -1;
@@ -23,7 +41,6 @@ function hasModifierChord(event) {
23
41
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
24
42
  }
25
43
 
26
- // src/utils/declared_value.ts
27
44
  function parseDeclared(raw, parse, fallback) {
28
45
  try {
29
46
  return parse(raw);
@@ -43,31 +60,78 @@ function validSelector(element, raw, fallback) {
43
60
  );
44
61
  }
45
62
 
46
- // src/utils/roving_tabindex.ts
63
+ var ListenerSet = class {
64
+ #abort = new AbortController();
65
+ add(target, type, handler, options) {
66
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
67
+ }
68
+ dispose() {
69
+ this.#abort.abort();
70
+ this.#abort = new AbortController();
71
+ }
72
+ };
73
+
74
+ var MicrotaskCoalescer = class {
75
+ #run;
76
+ #queued = false;
77
+ #active = false;
78
+ #generation = 0;
79
+ constructor(run) {
80
+ this.#run = run;
81
+ }
82
+ activate() {
83
+ this.#active = true;
84
+ }
85
+ cancel() {
86
+ this.#active = false;
87
+ this.#queued = false;
88
+ this.#generation += 1;
89
+ }
90
+ schedule() {
91
+ if (!this.#active || this.#queued) return;
92
+ this.#queued = true;
93
+ const generation = this.#generation;
94
+ queueMicrotask(() => {
95
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
96
+ this.#queued = false;
97
+ this.#run();
98
+ });
99
+ }
100
+ };
101
+
102
+ var MorphRenderWatcher = class {
103
+ #listeners = new ListenerSet();
104
+ #pass;
105
+ #element = null;
106
+ #onMorph = () => this.#pass.schedule();
107
+ constructor(run) {
108
+ this.#pass = new MicrotaskCoalescer(run);
109
+ }
110
+ observe(element) {
111
+ if (this.#element === element) return;
112
+ this.disconnect();
113
+ this.#element = element;
114
+ this.#pass.activate();
115
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
116
+ }
117
+ schedule() {
118
+ this.#pass.schedule();
119
+ }
120
+ disconnect() {
121
+ this.#listeners.dispose();
122
+ this.#pass.cancel();
123
+ this.#element = null;
124
+ }
125
+ };
126
+
47
127
  var RovingTabindex = class {
48
- /** Returns the current ordered item elements; called on every operation. */
49
128
  #getItems;
50
- /**
51
- * @param getItems - Returns the current ordered item elements. Called on every
52
- * operation so the live target list is always used.
53
- */
54
129
  constructor(getItems) {
55
130
  this.#getItems = getItems;
56
131
  }
57
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
58
132
  get activeIndex() {
59
133
  return this.#getItems().findIndex((item) => item.tabIndex === 0);
60
134
  }
61
- /**
62
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
63
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
64
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
65
- * "nothing is currently tabbable".
66
- *
67
- * @param index - Position of the item to make tabbable.
68
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
69
- * and `items` to reuse an event-scoped collection snapshot.
70
- */
71
135
  setActive(index, options = {}) {
72
136
  const { focus = false } = options;
73
137
  const items = options.items ?? this.#getItems();
@@ -83,7 +147,6 @@ function rovingMove(current, length, delta, wrap) {
83
147
  return (next + length) % length;
84
148
  }
85
149
 
86
- // src/utils/safe_storage.ts
87
150
  function readLocalStorage(key) {
88
151
  try {
89
152
  return { ok: true, value: window.localStorage.getItem(key) };
@@ -100,7 +163,10 @@ function writeLocalStorage(key, value) {
100
163
  }
101
164
  }
102
165
 
103
- // src/controllers/theme_controller.ts
166
+ function targetSelector(identifier, name) {
167
+ return `[data-${identifier}-target~="${name}"]`;
168
+ }
169
+
104
170
  var MODES = ["light", "dark", "system"];
105
171
  var isMode = (value) => typeof value === "string" && MODES.includes(value);
106
172
  var DEFAULT_MODE = "system";
@@ -114,30 +180,19 @@ var ThemeController = class extends Controller {
114
180
  };
115
181
  static actions = ["set", "toggle"];
116
182
  static events = ["change", "reconcile"];
117
- /** The OS dark-mode query, watched so `system` tracks live changes. */
118
183
  #media = null;
119
- /** Gate for the target and `mode` callbacks, which Stimulus runs before `connect()`. */
120
184
  #connected = false;
121
- /** The `target` declaration after validation; the default when unparsable. */
122
185
  #targetSelector = DEFAULT_TARGET;
123
- /** Owns the single Tab stop across the option set (APG radiogroup). */
186
+ #storageKeyRead = null;
124
187
  #roving = new RovingTabindex(() => this.optionTargets);
125
- /**
126
- * The pair on screen as last reported or re-seeded, so a move can be told from a
127
- * repeat. Neither side is readable after the fact — assigning the Value updates the
128
- * mode before any comparison, and the OS query has already flipped by the time it
129
- * notifies — so what was reported has to be kept rather than recomputed.
130
- */
131
188
  #published = {
132
189
  mode: DEFAULT_MODE,
133
190
  resolved: "light"
134
191
  };
135
- /** Re-resolves while in `system` mode when the OS preference flips. */
136
192
  #onMediaChange = () => {
137
193
  if (this.#mode !== "system") return;
138
- this.#commit();
194
+ this.#commit("media");
139
195
  };
140
- /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
141
196
  #onKeydown = (event) => {
142
197
  if (event.defaultPrevented) return;
143
198
  if (isReservedArrowChord(event)) return;
@@ -169,7 +224,13 @@ var ThemeController = class extends Controller {
169
224
  const mode = this.#optionMode(option);
170
225
  if (mode) this.#setMode(mode);
171
226
  };
227
+ #morph = new MorphRenderWatcher(() => {
228
+ this.#applyTheme();
229
+ this.#reconcileControls();
230
+ });
172
231
  connect() {
232
+ this.#morph.observe(this.element);
233
+ this.#storageKeyRead = this.storageKeyValue;
173
234
  const stored = this.#readStored();
174
235
  if (stored) this.modeValue = stored;
175
236
  this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
@@ -181,25 +242,15 @@ var ThemeController = class extends Controller {
181
242
  this.#published = this.#current;
182
243
  }
183
244
  disconnect() {
245
+ this.#morph.disconnect();
184
246
  this.#connected = false;
185
247
  this.#media?.removeEventListener("change", this.#onMediaChange);
186
248
  this.element.removeEventListener("keydown", this.#onKeydown);
187
249
  }
188
- /** Validates the `target` declaration once, so the render path never parses. */
189
250
  targetValueChanged() {
190
251
  this.#targetSelector = validSelector(this.element, this.targetValue, DEFAULT_TARGET);
252
+ this.#morph.schedule();
191
253
  }
192
- /**
193
- * Re-renders for a `mode` declaration changed after connect — a Turbo morph
194
- * re-rendering the server's markup over the live element, or a script.
195
- *
196
- * A stored choice outranks the declaration: a Value that disagrees with it is written
197
- * back to it, so a morph that brings the server's default never overrides what the
198
- * user saved. Without one, the declaration is applied. Either way storage is left
199
- * alone, and a pair on screen that moved is reported as `reconcile`, not `change` —
200
- * the page moved the declaration, not a selection. The reported pair is re-seeded
201
- * first, so the next selection is compared with what is on screen.
202
- */
203
254
  modeValueChanged() {
204
255
  if (!this.#connected) return;
205
256
  if (this.modeValue === this.#published.mode) return;
@@ -208,67 +259,53 @@ var ThemeController = class extends Controller {
208
259
  this.#applyTheme();
209
260
  this.#reconcileControls();
210
261
  }
211
- /** Re-derives the single Tab stop and ARIA for an option set that changed. */
262
+ storageKeyValueChanged() {
263
+ if (!this.#connected) return;
264
+ const key = this.storageKeyValue;
265
+ if (key === this.#storageKeyRead) return;
266
+ this.#storageKeyRead = key;
267
+ const stored = this.#readStored();
268
+ if (stored === null || stored === this.modeValue) return;
269
+ this.modeValue = stored;
270
+ this.#applyTheme();
271
+ this.#reconcileControls();
272
+ }
212
273
  optionTargetConnected() {
213
- if (this.#connected) this.#reconcileControls();
274
+ if (this.#connected) this.#morph.schedule();
214
275
  }
215
- /** Re-derives them again when an option leaves, so a Tab stop always remains. */
216
276
  optionTargetDisconnected() {
217
- if (this.#connected) this.#reconcileControls();
218
- }
219
- /**
220
- * Selects the mode the activated option declares.
221
- *
222
- * Read through the same lane that decides which option is checked, so the two
223
- * can never disagree about what an option declares.
224
- */
225
- set(event) {
226
- const option = event.currentTarget;
227
- if (!(option instanceof HTMLElement)) return;
277
+ if (this.#connected) this.#morph.schedule();
278
+ }
279
+ set(source) {
280
+ const { host, origin, reason } = actionSource(source);
281
+ const option = host?.closest(targetSelector(this.identifier, "option"));
282
+ if (!option || !this.optionTargets.includes(option)) return;
283
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
284
+ return;
228
285
  const mode = this.#optionMode(option);
229
- if (mode) this.#setMode(mode);
286
+ if (mode) this.#setMode(mode, reason);
230
287
  }
231
- /** Toggles light↔dark for the 2-value single-button contract. */
232
- toggle() {
233
- this.#setMode(this.#resolved() === "dark" ? "light" : "dark");
288
+ toggle(event) {
289
+ const mode = this.#readStored() ?? this.#mode;
290
+ const dark = mode === "dark" || mode === "system" && this.#media?.matches === true;
291
+ this.#setMode(dark ? "light" : "dark", stateReasonFor(event));
234
292
  }
235
- /** Central mode change: persist, apply to the root, resync controls, announce. */
236
- #setMode(mode) {
293
+ #setMode(mode, reason = "user") {
237
294
  this.modeValue = mode;
238
295
  this.#writeStored(mode);
239
- this.#commit();
240
- }
241
- /**
242
- * Applies the current mode and reports it, but reports only a real move: the
243
- * event means "the selection or the effective theme moved", so re-choosing the
244
- * option already chosen is not one.
245
- */
246
- #commit() {
296
+ this.#commit(reason);
297
+ }
298
+ #commit(reason) {
247
299
  this.#applyTheme();
248
300
  this.#syncControls();
249
301
  const moved = this.#settle();
250
- if (moved) this.dispatch("change", { detail: moved });
251
- }
252
- /**
253
- * Re-derives the controls from the mode on screen for a change the page made — a
254
- * `mode` declaration or an option set — and reports a pair that moved since the
255
- * last report as `reconcile`. An option coming or going moves neither half of the
256
- * pair, so of these changes only a declaration reports.
257
- */
302
+ if (moved) this.dispatch("change", { detail: { ...moved, reason } });
303
+ }
258
304
  #reconcileControls() {
259
305
  this.#syncControls();
260
306
  const moved = this.#settle();
261
307
  if (moved) this.dispatch("reconcile", { detail: moved });
262
308
  }
263
- /**
264
- * Takes the pair on screen as the reported one and returns it when it differs
265
- * from the one reported before, or `null` when neither half moved.
266
- *
267
- * The baseline moves before anything is dispatched, so a listener that selects
268
- * another mode is measured from the pair it was just told about. What is
269
- * returned is a copy: the baseline has to survive a listener that writes to what
270
- * it was handed, or the next unchanged operation reads as a move.
271
- */
272
309
  #settle() {
273
310
  const next = this.#current;
274
311
  const last = this.#published;
@@ -276,15 +313,9 @@ var ThemeController = class extends Controller {
276
313
  if (last.mode === next.mode && last.resolved === next.resolved) return null;
277
314
  return { ...next };
278
315
  }
279
- /** The pair the `change` and `reconcile` details carry, read from current state. */
280
316
  get #current() {
281
317
  return { mode: this.#mode, resolved: this.#resolved() };
282
318
  }
283
- /**
284
- * Writes `data-theme` + `color-scheme` (the resolved theme) onto the target.
285
- *
286
- * @stimeoRenderRoot
287
- */
288
319
  #applyTheme() {
289
320
  const root = this.#targetElement();
290
321
  if (!root) return;
@@ -292,11 +323,6 @@ var ThemeController = class extends Controller {
292
323
  root.setAttribute("data-theme", resolved);
293
324
  root.style.setProperty("color-scheme", resolved);
294
325
  }
295
- /**
296
- * Keeps the radiogroup (aria-checked + roving tabindex) or toggle (aria-pressed) in sync.
297
- *
298
- * @stimeoRenderRoot
299
- */
300
326
  #syncControls() {
301
327
  const options = this.optionTargets;
302
328
  if (options.length > 0) {
@@ -314,43 +340,35 @@ var ThemeController = class extends Controller {
314
340
  this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
315
341
  }
316
342
  }
317
- /** Whether the controller element is the button of the 2-value contract. */
318
343
  get #isToggleButton() {
319
344
  return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
320
345
  }
321
- /** The selected mode after validation; an unreadable declaration is the default. */
322
346
  get #mode() {
323
347
  return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
324
348
  }
325
- /** The effective theme: the OS preference when `system`, else the mode itself. */
326
349
  #resolved() {
327
350
  const mode = this.#mode;
328
351
  if (mode === "dark") return "dark";
329
352
  if (mode === "light") return "light";
330
353
  return this.#media?.matches ? "dark" : "light";
331
354
  }
332
- /** An option's mode from its `data-value`, or `null` when that is not one of the three. */
333
355
  #optionMode(option) {
334
356
  const mode = option.getAttribute("data-value");
335
357
  return isMode(mode) ? mode : null;
336
358
  }
337
- /** Resolves the state-hook target (`<html>` by default). */
338
359
  #targetElement() {
339
360
  const selector = this.#targetSelector;
340
361
  if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
341
362
  return document.querySelector(selector);
342
363
  }
343
- /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
344
364
  #readStored() {
345
365
  const result = readLocalStorage(this.storageKeyValue);
346
366
  return result.ok && isMode(result.value) ? result.value : null;
347
367
  }
348
- /** Persists the mode, swallowing storage errors (private mode / quota). */
349
368
  #writeStored(mode) {
350
369
  writeLocalStorage(this.storageKeyValue, mode);
351
370
  }
352
371
  };
353
372
 
354
373
  export { ThemeController };
355
- //# sourceMappingURL=theme_controller.js.map
356
374
  //# sourceMappingURL=theme_controller.js.map