stimeo-ui 0.2.0 → 0.3.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/breadcrumb_controller.js +225 -13
  6. data/dist/controllers/calendar_controller.js +89 -22
  7. data/dist/controllers/carousel_controller.js +313 -0
  8. data/dist/controllers/clipboard_controller.js +144 -0
  9. data/dist/controllers/collapsible_controller.js +327 -0
  10. data/dist/controllers/color_picker_controller.js +252 -0
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +194 -17
  13. data/dist/controllers/context_menu_controller.js +32 -10
  14. data/dist/controllers/count_up_controller.js +8 -1
  15. data/dist/controllers/currency_input_controller.js +147 -0
  16. data/dist/controllers/data_grid_controller.js +246 -0
  17. data/dist/controllers/date_range_picker_controller.js +441 -0
  18. data/dist/controllers/dismissible_controller.js +117 -0
  19. data/dist/controllers/drawer_controller.js +630 -0
  20. data/dist/controllers/editable_controller.js +169 -0
  21. data/dist/controllers/file_dropzone_controller.js +165 -0
  22. data/dist/controllers/filter_controller.js +86 -0
  23. data/dist/controllers/flash_controller.js +36 -5
  24. data/dist/controllers/form_validation_controller.js +1 -1
  25. data/dist/controllers/highlight_controller.js +6 -4
  26. data/dist/controllers/intersection_controller.js +67 -19
  27. data/dist/controllers/lazy_frame_controller.js +54 -11
  28. data/dist/controllers/listbox_controller.js +257 -53
  29. data/dist/controllers/local_time_controller.js +2 -2
  30. data/dist/controllers/masonry_controller.js +142 -0
  31. data/dist/controllers/menu_controller.js +104 -17
  32. data/dist/controllers/menubar_controller.js +785 -0
  33. data/dist/controllers/multi_select_controller.js +755 -0
  34. data/dist/controllers/navigation_menu_controller.js +511 -0
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +246 -27
  38. data/dist/controllers/overflow_menu_controller.js +381 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/password_reveal_controller.js +117 -0
  41. data/dist/controllers/persist_controller.js +6 -6
  42. data/dist/controllers/pointer_drag_controller.js +9 -1
  43. data/dist/controllers/popover_controller.js +2 -2
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +192 -0
  46. data/dist/controllers/rating_controller.js +16 -2
  47. data/dist/controllers/read_more_controller.js +238 -0
  48. data/dist/controllers/resizable_controller.js +65 -1
  49. data/dist/controllers/roving_controller.js +17 -2
  50. data/dist/controllers/scroll_area_controller.js +101 -14
  51. data/dist/controllers/scroll_restore_controller.js +93 -0
  52. data/dist/controllers/scroll_visibility_controller.js +40 -6
  53. data/dist/controllers/scrollspy_controller.js +369 -74
  54. data/dist/controllers/separator_controller.js +96 -0
  55. data/dist/controllers/sidebar_controller.js +761 -0
  56. data/dist/controllers/skeleton_controller.js +1 -1
  57. data/dist/controllers/slider_controller.js +32 -6
  58. data/dist/controllers/sortable_controller.js +34 -3
  59. data/dist/controllers/spinner_controller.js +1 -1
  60. data/dist/controllers/stepper_controller.js +28 -12
  61. data/dist/controllers/stick_to_bottom_controller.js +9 -4
  62. data/dist/controllers/sticky_observer_controller.js +109 -20
  63. data/dist/controllers/switch_controller.js +1 -0
  64. data/dist/controllers/tabs_controller.js +26 -3
  65. data/dist/controllers/tags_input_controller.js +295 -0
  66. data/dist/controllers/theme_controller.js +42 -13
  67. data/dist/controllers/time_picker_controller.js +231 -0
  68. data/dist/controllers/toast_controller.js +40 -14
  69. data/dist/controllers/toggle_group_controller.js +23 -2
  70. data/dist/controllers/toolbar_controller.js +230 -31
  71. data/dist/controllers/transition_controller.js +153 -38
  72. data/dist/controllers/tree_view_controller.js +691 -0
  73. data/dist/index.js +4256 -915
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +28 -2
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/overflow_indicator_controller.ts
4
4
 
5
+ // src/utils/blur_deferral.ts
6
+ var BlurDeferral = class {
7
+ /** Elements currently holding an update back, mapped to their `blur` listener. */
8
+ #pending = /* @__PURE__ */ new Map();
9
+ /** Called after a pending element blurs and has been detached. */
10
+ #onRelease;
11
+ /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
12
+ constructor(onRelease) {
13
+ this.#onRelease = onRelease;
14
+ }
15
+ /** Number of elements currently holding an update back. */
16
+ get size() {
17
+ return this.#pending.size;
18
+ }
19
+ /** Snapshot of the pending elements, safe to iterate while releasing them. */
20
+ get elements() {
21
+ return [...this.#pending.keys()];
22
+ }
23
+ /** Whether `element` is currently holding an update back. */
24
+ has(element) {
25
+ return this.#pending.has(element);
26
+ }
27
+ /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
28
+ defer(element) {
29
+ if (this.#pending.has(element)) return;
30
+ const onBlur = () => {
31
+ this.#detach(element);
32
+ this.#onRelease(element);
33
+ };
34
+ this.#pending.set(element, onBlur);
35
+ element.addEventListener("blur", onBlur);
36
+ }
37
+ /** Defers `element` as the only pending entry, cancelling any others. */
38
+ deferOnly(element) {
39
+ for (const pending of this.elements) {
40
+ if (pending !== element) this.#detach(pending);
41
+ }
42
+ this.defer(element);
43
+ }
44
+ /** Cancels `element`'s deferral without completing it; no-ops when not pending. */
45
+ release(element) {
46
+ this.#detach(element);
47
+ }
48
+ /** Cancels every deferral without completing any of them. */
49
+ releaseAll() {
50
+ for (const element of this.elements) this.#detach(element);
51
+ }
52
+ /** Removes the `blur` listener for `element` and forgets it. */
53
+ #detach(element) {
54
+ const onBlur = this.#pending.get(element);
55
+ if (onBlur) element.removeEventListener("blur", onBlur);
56
+ this.#pending.delete(element);
57
+ }
58
+ };
59
+
5
60
  // src/utils/layout_observer.ts
6
61
  var LayoutObserver = class {
7
62
  #callback;
@@ -58,7 +113,30 @@ var LayoutObserver = class {
58
113
  }
59
114
  };
60
115
 
116
+ // src/utils/logical_scroll.ts
117
+ function isRtl(element) {
118
+ return window.getComputedStyle(element).direction === "rtl";
119
+ }
120
+ function logicalScrollMetrics(element, horizontal) {
121
+ const max = Math.max(
122
+ 0,
123
+ horizontal ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight
124
+ );
125
+ const raw = horizontal ? element.scrollLeft : element.scrollTop;
126
+ const position = horizontal && isRtl(element) ? -raw : raw;
127
+ return { position: Math.min(max, Math.max(0, position)), max };
128
+ }
129
+ function physicalScrollDelta(element, horizontal, logicalDelta) {
130
+ return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;
131
+ }
132
+
133
+ // src/utils/reduced_motion.ts
134
+ function prefersReducedMotion() {
135
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
136
+ }
137
+
61
138
  // src/controllers/overflow_indicator_controller.ts
139
+ var DIRECTION_BUTTON_SELECTOR = "[data-stimeo--overflow-indicator-direction-param]";
62
140
  var OverflowIndicatorController = class extends Controller {
63
141
  static targets = ["viewport"];
64
142
  static values = {
@@ -67,38 +145,59 @@ var OverflowIndicatorController = class extends Controller {
67
145
  };
68
146
  static actions = ["scrollByPage", "update"];
69
147
  static events = ["change"];
70
- #layout = new LayoutObserver(() => this.update());
148
+ #layout = new LayoutObserver(() => {
149
+ if (this.#connected) this.update();
150
+ });
151
+ #connected = false;
152
+ #observedViewport = null;
153
+ #observedContent = /* @__PURE__ */ new Set();
71
154
  #mutationObserver = null;
155
+ /**
156
+ * Boundary buttons whose native `disabled` is held back while they hold focus.
157
+ *
158
+ * Completing the deferral is what actually disables the button, so the release
159
+ * callback — unlike {@link #cancelPendingButtonDisable} — applies it.
160
+ */
161
+ #pendingButtonDisables = new BlurDeferral((button) => {
162
+ this.#restorePendingMarkers(button);
163
+ if (!button.disabled) this.#disableButton(button);
164
+ });
72
165
  /** Last reported room, so `change` fires only on transitions. */
73
166
  #state = null;
74
167
  connect() {
75
- if (!this.hasViewportTarget) return;
76
- this.#layout.observe(this.viewportTarget);
77
- this.#layout.observeViewport();
78
- if (typeof MutationObserver !== "undefined") {
79
- this.#mutationObserver = new MutationObserver(() => this.update());
80
- this.#mutationObserver.observe(this.viewportTarget, {
81
- childList: true,
82
- subtree: true,
83
- characterData: true
84
- });
85
- }
86
- this.update();
168
+ this.#connected = true;
169
+ this.#syncViewport();
87
170
  }
88
171
  disconnect() {
172
+ this.#connected = false;
173
+ this.#stopObservingViewport();
89
174
  this.#layout.disconnect();
90
- this.#mutationObserver?.disconnect();
91
- this.#mutationObserver = null;
175
+ this.#clearPendingButtonDisables();
92
176
  this.#state = null;
93
177
  }
94
- /** Re-measures remaining scroll room and reflects the state hooks. Public so it can be wired to the viewport's `scroll`. */
178
+ viewportTargetConnected() {
179
+ this.#syncViewport();
180
+ }
181
+ viewportTargetDisconnected(viewport) {
182
+ if (this.#observedViewport === viewport) this.#stopObservingViewport();
183
+ this.#syncViewport();
184
+ }
185
+ orientationValueChanged() {
186
+ if (this.#connected) this.update();
187
+ }
188
+ thresholdValueChanged() {
189
+ if (this.#connected) this.update();
190
+ }
191
+ /**
192
+ * Re-measures remaining scroll room and reflects the state hooks.
193
+ * Public so it can be wired to the viewport's `scroll`.
194
+ */
95
195
  update() {
96
196
  if (!this.hasViewportTarget) return;
97
197
  const vp = this.viewportTarget;
98
198
  const horizontal = this.orientationValue !== "vertical";
99
- const t = this.thresholdValue;
100
- const scrollPos = horizontal ? vp.scrollLeft : vp.scrollTop;
101
- const maxScroll = horizontal ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
199
+ const t = this.#threshold;
200
+ const { position: scrollPos, max: maxScroll } = logicalScrollMetrics(vp, horizontal);
102
201
  const start = scrollPos > t;
103
202
  const end = scrollPos < maxScroll - t;
104
203
  vp.setAttribute("data-overflow-start", start ? "true" : "false");
@@ -114,11 +213,13 @@ var OverflowIndicatorController = class extends Controller {
114
213
  if (!this.hasViewportTarget) return;
115
214
  const direction = this.#directionFromEvent(event);
116
215
  if (!direction) return;
216
+ if (this.#state && !this.#state[direction]) return;
117
217
  const vp = this.viewportTarget;
118
218
  const horizontal = this.orientationValue !== "vertical";
119
219
  const page = horizontal ? vp.clientWidth : vp.clientHeight;
120
- const delta = direction === "start" ? -page : page;
121
- const behavior = this.#prefersReducedMotion() ? "auto" : "smooth";
220
+ const logicalDelta = direction === "start" ? -page : page;
221
+ const delta = physicalScrollDelta(vp, horizontal, logicalDelta);
222
+ const behavior = prefersReducedMotion() ? "instant" : "smooth";
122
223
  if (horizontal) {
123
224
  vp.scrollBy({ left: delta, behavior });
124
225
  } else {
@@ -127,10 +228,16 @@ var OverflowIndicatorController = class extends Controller {
127
228
  }
128
229
  /** Mirrors remaining room onto any direction buttons by toggling `disabled`. */
129
230
  #syncButtons(start, end) {
130
- const buttons = this.element.querySelectorAll(
131
- "[data-stimeo--overflow-indicator-direction-param]"
132
- );
231
+ for (const button of this.#pendingButtonDisables.elements) {
232
+ if (!button.isConnected || button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
233
+ this.#cancelPendingButtonDisable(button);
234
+ }
235
+ }
236
+ const buttons = this.element.querySelectorAll(DIRECTION_BUTTON_SELECTOR);
133
237
  for (const button of buttons) {
238
+ if (button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
239
+ continue;
240
+ }
134
241
  const direction = button.getAttribute("data-stimeo--overflow-indicator-direction-param");
135
242
  if (direction === "start") this.#toggleButton(button, start);
136
243
  else if (direction === "end") this.#toggleButton(button, end);
@@ -143,7 +250,11 @@ var OverflowIndicatorController = class extends Controller {
143
250
  * whole control disabled) is therefore never blindly re-enabled.
144
251
  */
145
252
  #toggleButton(button, hasRoom) {
253
+ if (!this.#pendingButtonDisables.has(button) && button.hasAttribute("data-overflow-indicator-pending-disabled")) {
254
+ this.#cancelPendingButtonDisable(button);
255
+ }
146
256
  if (hasRoom) {
257
+ this.#cancelPendingButtonDisable(button);
147
258
  if (button.hasAttribute("data-overflow-indicator-disabled")) {
148
259
  button.disabled = false;
149
260
  button.removeAttribute("data-overflow-indicator-disabled");
@@ -151,17 +262,125 @@ var OverflowIndicatorController = class extends Controller {
151
262
  return;
152
263
  }
153
264
  if (button.disabled) return;
265
+ if (document.activeElement === button) {
266
+ this.#deferButtonDisable(button);
267
+ } else {
268
+ this.#disableButton(button);
269
+ }
270
+ }
271
+ /**
272
+ * Keeps a boundary button focusable until native blur, exposing its temporary
273
+ * inoperability with `aria-disabled` and making its action a no-op meanwhile.
274
+ */
275
+ #deferButtonDisable(button) {
276
+ if (this.#pendingButtonDisables.has(button)) return;
277
+ button.setAttribute("data-overflow-indicator-pending-disabled", "");
278
+ button.setAttribute(
279
+ "data-overflow-indicator-aria-disabled",
280
+ button.getAttribute("aria-disabled") ?? ""
281
+ );
282
+ button.setAttribute("aria-disabled", "true");
283
+ this.#pendingButtonDisables.defer(button);
284
+ }
285
+ #disableButton(button) {
154
286
  button.disabled = true;
155
287
  button.setAttribute("data-overflow-indicator-disabled", "");
156
288
  }
289
+ /** Cancels a pending disable without applying it, restoring the displaced state. */
290
+ #cancelPendingButtonDisable(button) {
291
+ this.#pendingButtonDisables.release(button);
292
+ this.#restorePendingMarkers(button);
293
+ }
294
+ /**
295
+ * Drops the pending marker and gives back the `aria-disabled` it displaced.
296
+ *
297
+ * Reads the displaced value from the DOM rather than memory: a Turbo snapshot is
298
+ * cloned before `disconnect()` runs, so a restored page can arrive with markers
299
+ * this instance never wrote.
300
+ */
301
+ #restorePendingMarkers(button) {
302
+ button.removeAttribute("data-overflow-indicator-pending-disabled");
303
+ const displaced = button.getAttribute("data-overflow-indicator-aria-disabled");
304
+ if (displaced !== null) {
305
+ button.removeAttribute("data-overflow-indicator-aria-disabled");
306
+ if (button.getAttribute("aria-disabled") === "true") {
307
+ if (displaced === "") button.removeAttribute("aria-disabled");
308
+ else button.setAttribute("aria-disabled", displaced);
309
+ }
310
+ }
311
+ }
312
+ #clearPendingButtonDisables() {
313
+ for (const button of this.#pendingButtonDisables.elements) {
314
+ this.#cancelPendingButtonDisable(button);
315
+ }
316
+ }
317
+ /** Moves resize/mutation/load observation to the current viewport target. */
318
+ #syncViewport() {
319
+ if (!this.#connected) return;
320
+ const next = this.hasViewportTarget ? this.viewportTarget : null;
321
+ if (next === this.#observedViewport) return;
322
+ this.#stopObservingViewport();
323
+ if (!next) return;
324
+ this.#observedViewport = next;
325
+ this.#layout.observe(next);
326
+ this.#layout.observeViewport();
327
+ next.addEventListener("load", this.#onContentLoad, true);
328
+ this.#syncContentObservation();
329
+ if (typeof MutationObserver !== "undefined") {
330
+ this.#mutationObserver = new MutationObserver(() => {
331
+ if (!this.#connected || this.#observedViewport !== next) return;
332
+ this.#syncContentObservation();
333
+ this.update();
334
+ });
335
+ this.#mutationObserver.observe(this.element, {
336
+ childList: true,
337
+ subtree: true,
338
+ characterData: true,
339
+ attributes: true,
340
+ attributeFilter: ["class", "style", "hidden"]
341
+ });
342
+ }
343
+ this.#state = null;
344
+ this.update();
345
+ }
346
+ /** Observes direct content boxes whose resize can change the viewport's scroll extent. */
347
+ #syncContentObservation() {
348
+ const next = new Set(this.#observedViewport?.children ?? []);
349
+ for (const content of this.#observedContent) {
350
+ if (!next.has(content)) {
351
+ this.#layout.unobserve(content);
352
+ this.#observedContent.delete(content);
353
+ }
354
+ }
355
+ for (const content of next) {
356
+ if (!this.#observedContent.has(content)) {
357
+ this.#observedContent.add(content);
358
+ this.#layout.observe(content);
359
+ }
360
+ }
361
+ }
362
+ #stopObservingViewport() {
363
+ this.#mutationObserver?.disconnect();
364
+ this.#mutationObserver = null;
365
+ this.#observedViewport?.removeEventListener("load", this.#onContentLoad, true);
366
+ if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
367
+ for (const content of this.#observedContent) this.#layout.unobserve(content);
368
+ this.#observedContent.clear();
369
+ this.#observedViewport = null;
370
+ this.#layout.unobserveViewport();
371
+ }
372
+ #onContentLoad = () => {
373
+ if (this.#connected) this.update();
374
+ };
375
+ get #threshold() {
376
+ const value = this.thresholdValue;
377
+ return Number.isFinite(value) ? Math.max(0, value) : 1;
378
+ }
157
379
  #directionFromEvent(event) {
158
380
  const params = event.params;
159
381
  const direction = params?.direction;
160
382
  return direction === "start" || direction === "end" ? direction : null;
161
383
  }
162
- #prefersReducedMotion() {
163
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
164
- }
165
384
  };
166
385
 
167
386
  export { OverflowIndicatorController };