stimeo-ui 0.16.0 → 0.18.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +27 -11
  4. data/dist/cable/index.js +384 -449
  5. data/dist/controllers/accordion_controller.js +174 -55
  6. data/dist/controllers/alert_dialog_controller.js +903 -235
  7. data/dist/controllers/announcer_controller.js +238 -205
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +414 -145
  10. data/dist/controllers/avatar_controller.js +185 -107
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +282 -58
  13. data/dist/controllers/calendar_controller.js +228 -260
  14. data/dist/controllers/carousel_controller.js +536 -436
  15. data/dist/controllers/character_counter_controller.js +493 -207
  16. data/dist/controllers/checkbox_controller.js +38 -74
  17. data/dist/controllers/clipboard_controller.js +261 -149
  18. data/dist/controllers/collapsible_controller.js +304 -120
  19. data/dist/controllers/color_picker_controller.js +178 -146
  20. data/dist/controllers/combobox_controller.js +522 -156
  21. data/dist/controllers/command_palette_controller.js +1156 -395
  22. data/dist/controllers/conditional_fields_controller.js +300 -171
  23. data/dist/controllers/confirm_controller.js +980 -262
  24. data/dist/controllers/context_menu_controller.js +346 -153
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +281 -158
  27. data/dist/controllers/currency_input_controller.js +575 -225
  28. data/dist/controllers/data_grid_controller.js +248 -214
  29. data/dist/controllers/date_range_picker_controller.js +338 -235
  30. data/dist/controllers/dialog_controller.js +899 -237
  31. data/dist/controllers/direct_upload_controller.js +97 -167
  32. data/dist/controllers/dirty_form_controller.js +72 -69
  33. data/dist/controllers/dismissible_controller.js +196 -24
  34. data/dist/controllers/drawer_controller.js +974 -355
  35. data/dist/controllers/dropdown_controller.js +339 -96
  36. data/dist/controllers/editable_controller.js +315 -88
  37. data/dist/controllers/empty_state_controller.js +79 -58
  38. data/dist/controllers/file_dropzone_controller.js +322 -211
  39. data/dist/controllers/filter_controller.js +77 -42
  40. data/dist/controllers/flash_controller.js +238 -347
  41. data/dist/controllers/focus_controller.js +679 -240
  42. data/dist/controllers/form_field_controller.js +156 -132
  43. data/dist/controllers/form_validation_controller.js +31 -97
  44. data/dist/controllers/frame_loading_controller.js +275 -217
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +327 -130
  47. data/dist/controllers/idle_controller.js +271 -101
  48. data/dist/controllers/input_mask_controller.js +483 -97
  49. data/dist/controllers/intersection_controller.js +150 -106
  50. data/dist/controllers/lazy_frame_controller.js +24 -68
  51. data/dist/controllers/listbox_controller.js +523 -240
  52. data/dist/controllers/local_time_controller.js +102 -77
  53. data/dist/controllers/masonry_controller.js +80 -96
  54. data/dist/controllers/menu_controller.js +302 -188
  55. data/dist/controllers/menubar_controller.js +232 -345
  56. data/dist/controllers/meter_controller.js +417 -87
  57. data/dist/controllers/multi_select_controller.js +785 -393
  58. data/dist/controllers/navigation_menu_controller.js +190 -250
  59. data/dist/controllers/nested_form_controller.js +396 -192
  60. data/dist/controllers/network_status_controller.js +268 -62
  61. data/dist/controllers/number_input_controller.js +610 -303
  62. data/dist/controllers/optimistic_controller.js +45 -87
  63. data/dist/controllers/otp_controller.js +658 -255
  64. data/dist/controllers/overflow_indicator_controller.js +122 -113
  65. data/dist/controllers/overflow_menu_controller.js +470 -295
  66. data/dist/controllers/pagination_controller.js +332 -123
  67. data/dist/controllers/password_reveal_controller.js +392 -121
  68. data/dist/controllers/password_strength_controller.js +111 -158
  69. data/dist/controllers/persist_controller.js +165 -111
  70. data/dist/controllers/pointer_drag_controller.js +434 -188
  71. data/dist/controllers/popover_controller.js +269 -113
  72. data/dist/controllers/portal_controller.js +215 -76
  73. data/dist/controllers/preview_guard_controller.js +243 -116
  74. data/dist/controllers/progress_controller.js +391 -78
  75. data/dist/controllers/radio_group_controller.js +278 -211
  76. data/dist/controllers/range_slider_controller.js +220 -113
  77. data/dist/controllers/rating_controller.js +458 -211
  78. data/dist/controllers/read_more_controller.js +138 -63
  79. data/dist/controllers/reading_progress_controller.js +166 -137
  80. data/dist/controllers/relative_time_controller.js +211 -112
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
  82. data/dist/controllers/resizable_controller.js +299 -150
  83. data/dist/controllers/roving_controller.js +49 -88
  84. data/dist/controllers/scroll_area_controller.js +490 -246
  85. data/dist/controllers/scroll_restore_controller.js +14 -55
  86. data/dist/controllers/scroll_visibility_controller.js +432 -149
  87. data/dist/controllers/scrollspy_controller.js +193 -248
  88. data/dist/controllers/separator_controller.js +285 -142
  89. data/dist/controllers/sidebar_controller.js +1069 -404
  90. data/dist/controllers/skeleton_controller.js +259 -101
  91. data/dist/controllers/slider_controller.js +187 -86
  92. data/dist/controllers/smart_sticky_header_controller.js +173 -66
  93. data/dist/controllers/sortable_controller.js +26 -125
  94. data/dist/controllers/spinner_controller.js +288 -173
  95. data/dist/controllers/step_indicator_controller.js +105 -46
  96. data/dist/controllers/stepper_controller.js +120 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +275 -140
  98. data/dist/controllers/sticky_observer_controller.js +220 -49
  99. data/dist/controllers/submit_once_controller.js +376 -227
  100. data/dist/controllers/switch_controller.js +201 -84
  101. data/dist/controllers/tabs_controller.js +41 -30
  102. data/dist/controllers/tags_input_controller.js +293 -173
  103. data/dist/controllers/textarea_autosize_controller.js +108 -74
  104. data/dist/controllers/theme_controller.js +140 -116
  105. data/dist/controllers/time_picker_controller.js +331 -127
  106. data/dist/controllers/toast_controller.js +244 -324
  107. data/dist/controllers/toggle_group_controller.js +252 -191
  108. data/dist/controllers/toolbar_controller.js +219 -120
  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 +283 -369
  112. data/dist/index.js +8573 -10410
  113. data/dist/positioning/index.js +154 -47
  114. data/lib/generators/stimeo/install/install_generator.rb +7 -2
  115. data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
  116. data/lib/stimeo/ui/version.rb +1 -1
  117. metadata +3 -3
@@ -1,8 +1,45 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/local_time_controller.ts
3
+ var DATETIME = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
4
+ function readDatetime(element) {
5
+ const raw = element.getAttribute("datetime");
6
+ if (raw === null) return null;
7
+ const match = DATETIME.exec(raw.trim());
8
+ if (match === null) return null;
9
+ const [
10
+ ,
11
+ yearText,
12
+ monthText,
13
+ dayText,
14
+ hourText = "00",
15
+ minuteText = "00",
16
+ secondText = "00",
17
+ fraction = "",
18
+ zone = "Z"
19
+ ] = match;
20
+ const year = Number(yearText);
21
+ const month = Number(monthText);
22
+ const day = Number(dayText);
23
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
24
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
25
+ if (month < 1 || month > 12 || day < 1 || day > (days[month - 1] ?? 0)) return null;
26
+ const hour = Number(hourText);
27
+ const minute = Number(minuteText);
28
+ const second = Number(secondText);
29
+ if (hour === 24 && (minute !== 0 || second !== 0 || /[1-9]/.test(fraction))) return null;
30
+ let offset = zone;
31
+ if (zone !== "Z") {
32
+ const digits = zone.slice(1).replace(":", "");
33
+ const offsetHour = digits.slice(0, 2);
34
+ const offsetMinute = digits.slice(2);
35
+ offset = `${zone[0]}${offsetHour}:${offsetMinute}`;
36
+ }
37
+ const milliseconds = fraction.slice(0, 3).padEnd(3, "0");
38
+ const canonical = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}.${milliseconds}${offset}`;
39
+ const instant = Date.parse(canonical);
40
+ return Number.isNaN(instant) ? null : instant;
41
+ }
4
42
 
5
- // src/utils/intl_format.ts
6
43
  var formatters = /* @__PURE__ */ new WeakMap();
7
44
  function intlFormatter(factory, locale, options, fallbackLocale) {
8
45
  const cache = cacheFor(factory);
@@ -31,32 +68,40 @@ function construct(factory, locale, options, fallbackLocale) {
31
68
  }
32
69
  }
33
70
 
34
- // src/utils/locale.ts
35
71
  function resolveLocale(element, declared = "") {
36
72
  return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
37
73
  }
38
74
 
39
- // src/utils/microtask_coalescer.ts
75
+ var ListenerSet = class {
76
+ #abort = new AbortController();
77
+ add(target, type, handler, options) {
78
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
79
+ }
80
+ dispose() {
81
+ this.#abort.abort();
82
+ this.#abort = new AbortController();
83
+ }
84
+ };
85
+
40
86
  var MicrotaskCoalescer = class {
41
87
  #run;
42
88
  #queued = false;
43
89
  #active = false;
44
90
  #generation = 0;
45
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
46
91
  constructor(run) {
47
92
  this.#run = run;
48
93
  }
49
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
50
94
  activate() {
51
95
  this.#active = true;
52
96
  }
53
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
54
97
  cancel() {
55
98
  this.#active = false;
56
99
  this.#queued = false;
57
100
  this.#generation += 1;
58
101
  }
59
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
102
+ get pending() {
103
+ return this.#queued;
104
+ }
60
105
  schedule() {
61
106
  if (!this.#active || this.#queued) return;
62
107
  this.#queued = true;
@@ -69,7 +114,34 @@ var MicrotaskCoalescer = class {
69
114
  }
70
115
  };
71
116
 
72
- // src/controllers/local_time_controller.ts
117
+ var MorphRenderWatcher = class {
118
+ #listeners = new ListenerSet();
119
+ #pass;
120
+ #element = null;
121
+ #onMorph = () => this.#pass.schedule();
122
+ constructor(run) {
123
+ this.#pass = new MicrotaskCoalescer(run);
124
+ }
125
+ observe(element) {
126
+ if (this.#element === element) return;
127
+ this.disconnect();
128
+ this.#element = element;
129
+ this.#pass.activate();
130
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
131
+ }
132
+ schedule() {
133
+ this.#pass.schedule();
134
+ }
135
+ get pending() {
136
+ return this.#pass.pending;
137
+ }
138
+ disconnect() {
139
+ this.#listeners.dispose();
140
+ this.#pass.cancel();
141
+ this.#element = null;
142
+ }
143
+ };
144
+
73
145
  var STYLES = /* @__PURE__ */ new Set(["full", "long", "medium", "short"]);
74
146
  function toStyle(value) {
75
147
  return STYLES.has(value) ? value : void 0;
@@ -83,55 +155,48 @@ var LocalTimeController = class extends Controller {
83
155
  titleFormat: { type: String, default: "" }
84
156
  };
85
157
  static events = ["format"];
86
- /** Collapses a morph that swaps several render inputs at once into one repaint. */
87
- #resync = new MicrotaskCoalescer(() => this.#render());
88
- /**
89
- * Watches the one render input that is not a Value. Only `datetime` is filtered
90
- * in, so the text and `title` this controller writes cannot re-enter the pass.
91
- */
158
+ #resync = new MorphRenderWatcher(() => {
159
+ const datetimeChanged = this.#datetimeWatch.takeRecords().length > 0;
160
+ const notify = this.#formatPending || datetimeChanged;
161
+ this.#formatPending = false;
162
+ this.#render(notify);
163
+ });
164
+ #formatPending = false;
92
165
  #datetimeWatch = new MutationObserver(() => {
166
+ this.#formatPending = true;
93
167
  this.#resync.schedule();
94
168
  });
95
169
  connect() {
96
- this.#resync.activate();
170
+ this.#resync.observe(this.element);
97
171
  this.#datetimeWatch.observe(this.element, { attributeFilter: ["datetime"] });
172
+ this.#formatPending = false;
98
173
  this.#render();
99
174
  }
100
175
  disconnect() {
101
- this.#resync.cancel();
176
+ this.#resync.disconnect();
102
177
  this.#datetimeWatch.disconnect();
103
178
  }
104
- /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
105
179
  localeValueChanged() {
180
+ this.#formatPending = true;
106
181
  this.#resync.schedule();
107
182
  }
108
- /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
109
183
  timeZoneValueChanged() {
184
+ this.#formatPending = true;
110
185
  this.#resync.schedule();
111
186
  }
112
- /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
113
187
  dateStyleValueChanged() {
188
+ this.#formatPending = true;
114
189
  this.#resync.schedule();
115
190
  }
116
- /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
117
191
  timeStyleValueChanged() {
192
+ this.#formatPending = true;
118
193
  this.#resync.schedule();
119
194
  }
120
- /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
121
195
  titleFormatValueChanged() {
196
+ this.#formatPending = true;
122
197
  this.#resync.schedule();
123
198
  }
124
- /**
125
- * Formats the instant in `datetime` against the current Values and writes it out.
126
- *
127
- * The `format` event rides with every pass, including a repaint a morph triggers:
128
- * its condition is that formatting was applied, and a repaint applies it with a
129
- * new result. A pass that cannot format writes nothing and emits nothing, so the
130
- * authored absolute text stays as the fallback.
131
- *
132
- * @stimeoRenderRoot
133
- */
134
- #render() {
199
+ #render(notify = true) {
135
200
  const date = this.#parse();
136
201
  if (date === null) return;
137
202
  const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
@@ -139,55 +204,16 @@ var LocalTimeController = class extends Controller {
139
204
  this.element.textContent = formatted;
140
205
  const title = this.#title(date);
141
206
  if (title !== null) this.element.setAttribute("title", title);
142
- this.dispatch("format", { detail: { formatted } });
207
+ if (notify) this.dispatch("format", { detail: { formatted } });
143
208
  }
144
- /**
145
- * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace
146
- * around the attribute value is tolerated.
147
- */
148
209
  #parse() {
149
- const raw = this.element.getAttribute("datetime");
150
- if (!raw) return null;
151
- const ms = Date.parse(this.#asUtc(raw.trim()));
152
- return Number.isNaN(ms) ? null : new Date(ms);
153
- }
154
- /**
155
- * Reads a timezone-less date-time as UTC — the input contract of this
156
- * controller — since `Date.parse` would otherwise read `"2026-06-08T12:30:00"` in
157
- * the *runtime's* local zone, contradicting "the server emits UTC". Values that
158
- * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
159
- * parsed as UTC) are returned unchanged.
160
- *
161
- * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is
162
- * left to each engine, so a whole value shaped that way is normalized to the `T`
163
- * separator first. The pattern is anchored: a value trailing anything else — a
164
- * zone word such as `"2026-06-08 12:30:00 UTC"` — is handed to `Date.parse` as
165
- * authored instead of being turned into a string nothing can parse.
166
- */
167
- #asUtc(value) {
168
- const isoLike = value.replace(
169
- /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$/,
170
- "$1T$2"
171
- );
172
- const hasTime = /T\d{2}:\d{2}/.test(isoLike);
173
- const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(isoLike);
174
- return hasTime && !hasZone ? `${isoLike}Z` : isoLike;
175
- }
176
- /**
177
- * Builds the optional detailed `title`. `titleFormat` is an `Intl` style
178
- * keyword applied to *both* date and time; empty (the default) adds no title.
179
- */
210
+ const instant = readDatetime(this.element);
211
+ return instant === null ? null : new Date(instant);
212
+ }
180
213
  #title(date) {
181
214
  if (this.titleFormatValue.length === 0) return null;
182
215
  return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);
183
216
  }
184
- /**
185
- * Formats `date` with `Intl.DateTimeFormat`, including each style only when it
186
- * is a valid keyword (so a consumer can show date-only or time-only by clearing
187
- * the other). Returns `null` when neither style is usable or the declaration
188
- * names a locale or time zone `Intl` rejects, so the caller can leave the
189
- * authored text untouched.
190
- */
191
217
  #applyFormat(date, dateStyle, timeStyle) {
192
218
  const options = {
193
219
  dateStyle: toStyle(dateStyle),
@@ -205,5 +231,4 @@ var LocalTimeController = class extends Controller {
205
231
  };
206
232
 
207
233
  export { LocalTimeController };
208
- //# sourceMappingURL=local_time_controller.js.map
209
234
  //# sourceMappingURL=local_time_controller.js.map
@@ -1,19 +1,21 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/masonry_controller.ts
3
+ function setAttributeIfChanged(element, name, value, beforeWrite) {
4
+ if (element.getAttribute(name) === value) return false;
5
+ if (value === null) element.removeAttribute(name);
6
+ else element.setAttribute(name, value);
7
+ return true;
8
+ }
4
9
 
5
- // src/utils/layout_observer.ts
6
10
  var LayoutObserver = class {
7
11
  #callback;
8
12
  #resizeObserverFactory;
9
13
  #resizeObserver = null;
10
14
  #observingViewport = false;
11
15
  #loadContainer = null;
12
- /** Stable bound handler so add/removeEventListener target the same reference. */
13
16
  #handleViewportResize = () => {
14
17
  this.#callback();
15
18
  };
16
- /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
17
19
  #handleDescendantLoad = () => {
18
20
  this.#callback();
19
21
  };
@@ -21,11 +23,6 @@ var LayoutObserver = class {
21
23
  this.#callback = callback;
22
24
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
23
25
  }
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
26
  observe(element) {
30
27
  if (!this.#resizeObserverFactory) return;
31
28
  if (!this.#resizeObserver) {
@@ -35,47 +32,28 @@ var LayoutObserver = class {
35
32
  }
36
33
  this.#resizeObserver.observe(element);
37
34
  }
38
- /** Stops observing a single element while leaving any others in place. */
39
35
  unobserve(element) {
40
36
  this.#resizeObserver?.unobserve(element);
41
37
  }
42
- /** Starts observing viewport resizes. Idempotent: the listener is added once. */
43
38
  observeViewport() {
44
39
  if (this.#observingViewport) return;
45
40
  this.#observingViewport = true;
46
41
  window.addEventListener("resize", this.#handleViewportResize);
47
42
  }
48
- /** Stops observing viewport resizes without affecting element observation. */
49
43
  unobserveViewport() {
50
44
  if (!this.#observingViewport) return;
51
45
  this.#observingViewport = false;
52
46
  window.removeEventListener("resize", this.#handleViewportResize);
53
47
  }
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
48
  observeDescendantLoads(container) {
65
49
  this.unobserveDescendantLoads();
66
50
  this.#loadContainer = container;
67
51
  container.addEventListener("load", this.#handleDescendantLoad, true);
68
52
  }
69
- /** Stops reporting descendant loads without affecting element or viewport observation. */
70
53
  unobserveDescendantLoads() {
71
54
  this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
72
55
  this.#loadContainer = null;
73
56
  }
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
57
  disconnect() {
80
58
  this.#resizeObserver?.disconnect();
81
59
  this.#resizeObserver = null;
@@ -84,27 +62,25 @@ var LayoutObserver = class {
84
62
  }
85
63
  };
86
64
 
87
- // src/utils/microtask_coalescer.ts
88
65
  var MicrotaskCoalescer = class {
89
66
  #run;
90
67
  #queued = false;
91
68
  #active = false;
92
69
  #generation = 0;
93
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
94
70
  constructor(run) {
95
71
  this.#run = run;
96
72
  }
97
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
98
73
  activate() {
99
74
  this.#active = true;
100
75
  }
101
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
102
76
  cancel() {
103
77
  this.#active = false;
104
78
  this.#queued = false;
105
79
  this.#generation += 1;
106
80
  }
107
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
81
+ get pending() {
82
+ return this.#queued;
83
+ }
108
84
  schedule() {
109
85
  if (!this.#active || this.#queued) return;
110
86
  this.#queued = true;
@@ -117,69 +93,87 @@ var MicrotaskCoalescer = class {
117
93
  }
118
94
  };
119
95
 
120
- // src/controllers/masonry_controller.ts
96
+ var NUMBER_BOUNDS = {
97
+ finite: { finite: true }};
98
+ function matchesNumberBounds(value, bounds) {
99
+ if (!Number.isFinite(value)) {
100
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
101
+ if (direction === null) return false;
102
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
103
+ }
104
+ if (bounds.min !== void 0 && value < bounds.min) return false;
105
+ if (bounds.max !== void 0 && value > bounds.max) return false;
106
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
107
+ if (bounds.integer && !Number.isInteger(value)) return false;
108
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
109
+ return true;
110
+ }
111
+
112
+ function readNumber(raw, fallback, bounds) {
113
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
114
+ }
115
+
116
+ var NumberValueReader = class {
117
+ #lastRejected = /* @__PURE__ */ new Map();
118
+ read(owner, name, raw, fallback, bounds) {
119
+ const resolved = readNumber(raw, fallback, bounds);
120
+ if (matchesNumberBounds(raw, bounds)) {
121
+ this.#lastRejected.delete(name);
122
+ return resolved;
123
+ }
124
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
125
+ const literal = owner.element.getAttribute(attribute);
126
+ if (literal === null) {
127
+ this.#lastRejected.delete(name);
128
+ return resolved;
129
+ }
130
+ if (this.#lastRejected.get(name) !== literal) {
131
+ this.#lastRejected.set(name, literal);
132
+ console.warn(
133
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
134
+ );
135
+ }
136
+ return resolved;
137
+ }
138
+ };
139
+
121
140
  var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
122
141
  var DEFAULT_MIN_COLUMN_WIDTH = 240;
123
142
  var DEFAULT_GAP = 16;
124
- function usableNumber(value, fallback) {
125
- return Number.isFinite(value) ? value : fallback;
126
- }
127
- var MasonryController = class extends Controller {
143
+ var MasonryController = class _MasonryController extends Controller {
144
+ #numbers = new NumberValueReader();
128
145
  static targets = ["item"];
129
146
  static values = {
130
147
  minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
131
148
  gap: { type: Number, default: DEFAULT_GAP }
132
149
  };
150
+ static valueConstraints = {
151
+ minColumnWidth: NUMBER_BOUNDS.finite,
152
+ gap: NUMBER_BOUNDS.finite
153
+ };
133
154
  static events = ["layout"];
134
- /**
135
- * The declared numbers after validation, so the layout path never sees a value
136
- * it cannot compute with. Both are resolved once per declaration change rather
137
- * than on every pass.
138
- */
139
155
  #minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;
140
156
  #gap = DEFAULT_GAP;
141
- /**
142
- * Collapses every re-layout trigger of one DOM mutation into a single pass, and
143
- * refuses to run before `connect()` or after `disconnect()`.
144
- *
145
- * The triggers arrive in bursts — a resize stream, a morph that syncs several
146
- * attributes, a batch of rows — and each pass measures every item, so folding
147
- * them keeps the work proportional to the batch rather than to the events in it.
148
- */
149
157
  #reconcile = new MicrotaskCoalescer(() => this.#relayout());
150
- /** Items that left the target set and still carry the column hook. */
151
158
  #released = /* @__PURE__ */ new Set();
152
159
  #layout = new LayoutObserver(() => this.#reconcile.schedule());
153
160
  #mutationObserver = null;
154
- /** Last published column count, so `layout` fires only on real changes. */
155
161
  #lastColumns = 0;
156
- /** Resolves the declared column width once, falling back when it is unreadable. */
157
162
  minColumnWidthValueChanged() {
158
- this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
163
+ this.#minColumnWidth = this.#safeMinColumnWidth;
159
164
  this.#reconcile.schedule();
160
165
  }
161
- /** Resolves the declared gap once, falling back when it is unreadable. */
162
166
  gapValueChanged() {
163
- this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);
167
+ this.#gap = this.#safeGap;
164
168
  this.#reconcile.schedule();
165
169
  }
166
- /** Packs an element that became an item without moving in the DOM. */
167
170
  itemTargetConnected() {
168
171
  this.#reconcile.schedule();
169
172
  }
170
- /**
171
- * Queues the column hook of an element that stopped being an item for removal.
172
- *
173
- * The removal is queued rather than immediate because teardown reports every
174
- * target as disconnected: doing it here would strip the whole grid just before
175
- * a Turbo snapshot is taken. The coalescer's `cancel` drops the queue
176
- * with the pass, so only a genuine target change reaches it.
177
- */
178
173
  itemTargetDisconnected(item) {
179
174
  this.#released.add(item);
180
175
  this.#reconcile.schedule();
181
176
  }
182
- /** Observes size/content changes and performs the first layout pass. */
183
177
  connect() {
184
178
  this.#layout.observe(this.element);
185
179
  this.#layout.observeViewport();
@@ -191,31 +185,13 @@ var MasonryController = class extends Controller {
191
185
  this.#relayout();
192
186
  this.#reconcile.activate();
193
187
  }
194
- /** Releases every observation so nothing fires after detach. */
195
188
  disconnect() {
196
189
  this.#reconcile.cancel();
197
- this.#released.clear();
198
190
  this.#layout.disconnect();
199
191
  this.#mutationObserver?.disconnect();
200
192
  this.#mutationObserver = null;
201
193
  this.#lastColumns = 0;
202
194
  }
203
- /**
204
- * Recomputes the column count and assigns every item to the shortest column.
205
- * Runs automatically on connect, on resize, on item add/remove, when a declared
206
- * number changes, and when a descendant resource loads (private — there is no
207
- * public action; the observers, the target callbacks and the capture-phase
208
- * `load` listener drive it). Items are walked in DOM order; each lands in the
209
- * column with the least accumulated height, which keeps the packing balanced
210
- * without reordering the DOM.
211
- *
212
- * Every box is measured before anything is written. Interleaving the two would
213
- * make a consumer's `data-column` rule invalidate style once per item, and the
214
- * next measurement then has to settle layout again — once per item instead of
215
- * once per pass. The assignment is independent of the measurement because the
216
- * columns are uniform in width, so the order of the two passes does not change
217
- * the result.
218
- */
219
195
  #relayout() {
220
196
  const items = this.itemTargets;
221
197
  const columns = this.#columnCount();
@@ -239,10 +215,7 @@ var MasonryController = class extends Controller {
239
215
  if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
240
216
  }
241
217
  const assigned = String(shortest);
242
- if (item.getAttribute("data-column") !== assigned) {
243
- item.setAttribute("data-column", assigned);
244
- changed = true;
245
- }
218
+ if (setAttributeIfChanged(item, "data-column", assigned)) changed = true;
246
219
  heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;
247
220
  });
248
221
  this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
@@ -251,20 +224,31 @@ var MasonryController = class extends Controller {
251
224
  this.dispatch("layout", { detail: { columns } });
252
225
  }
253
226
  }
254
- /**
255
- * Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,
256
- * never fewer than one. When the width is unmeasurable (detached, or a layout
257
- * engine that reports `0`), it falls back to a single column so every item still
258
- * gets a valid `data-column`.
259
- */
260
227
  #columnCount() {
261
228
  const width = this.element.getBoundingClientRect().width;
262
229
  const denominator = this.#minColumnWidth + this.#gap;
263
230
  if (width <= 0 || denominator <= 0) return 1;
264
231
  return Math.max(1, Math.floor((width + this.#gap) / denominator));
265
232
  }
233
+ get #safeMinColumnWidth() {
234
+ return this.#numbers.read(
235
+ this,
236
+ "minColumnWidth",
237
+ this.minColumnWidthValue,
238
+ _MasonryController.values.minColumnWidth.default,
239
+ _MasonryController.valueConstraints.minColumnWidth
240
+ );
241
+ }
242
+ get #safeGap() {
243
+ return this.#numbers.read(
244
+ this,
245
+ "gap",
246
+ this.gapValue,
247
+ _MasonryController.values.gap.default,
248
+ _MasonryController.valueConstraints.gap
249
+ );
250
+ }
266
251
  };
267
252
 
268
253
  export { MasonryController };
269
- //# sourceMappingURL=masonry_controller.js.map
270
254
  //# sourceMappingURL=masonry_controller.js.map