stimeo-ui 0.1.0.pre.alpha.1

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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +72 -0
  4. data/dist/controllers/accordion_controller.js +76 -0
  5. data/dist/controllers/announcer_controller.js +184 -0
  6. data/dist/controllers/aspect_ratio_controller.js +36 -0
  7. data/dist/controllers/auto_submit_controller.js +147 -0
  8. data/dist/controllers/avatar_controller.js +66 -0
  9. data/dist/controllers/breadcrumb_controller.js +123 -0
  10. data/dist/controllers/bulk_select_controller.js +104 -0
  11. data/dist/controllers/calendar_controller.js +394 -0
  12. data/dist/controllers/character_counter_controller.js +179 -0
  13. data/dist/controllers/checkbox_controller.js +73 -0
  14. data/dist/controllers/combobox_controller.js +186 -0
  15. data/dist/controllers/command_palette_controller.js +381 -0
  16. data/dist/controllers/conditional_fields_controller.js +112 -0
  17. data/dist/controllers/confirm_controller.js +276 -0
  18. data/dist/controllers/context_menu_controller.js +112 -0
  19. data/dist/controllers/countdown_controller.js +202 -0
  20. data/dist/controllers/dialog_controller.js +207 -0
  21. data/dist/controllers/direct_upload_controller.js +212 -0
  22. data/dist/controllers/dirty_form_controller.js +128 -0
  23. data/dist/controllers/dropdown_controller.js +66 -0
  24. data/dist/controllers/empty_state_controller.js +67 -0
  25. data/dist/controllers/flash_controller.js +221 -0
  26. data/dist/controllers/focus_controller.js +216 -0
  27. data/dist/controllers/form_field_controller.js +154 -0
  28. data/dist/controllers/form_validation_controller.js +202 -0
  29. data/dist/controllers/frame_loading_controller.js +177 -0
  30. data/dist/controllers/highlight_controller.js +107 -0
  31. data/dist/controllers/hover_card_controller.js +165 -0
  32. data/dist/controllers/idle_controller.js +141 -0
  33. data/dist/controllers/input_mask_controller.js +166 -0
  34. data/dist/controllers/lazy_frame_controller.js +68 -0
  35. data/dist/controllers/listbox_controller.js +256 -0
  36. data/dist/controllers/local_time_controller.js +81 -0
  37. data/dist/controllers/menu_controller.js +134 -0
  38. data/dist/controllers/meter_controller.js +96 -0
  39. data/dist/controllers/nested_form_controller.js +131 -0
  40. data/dist/controllers/network_status_controller.js +126 -0
  41. data/dist/controllers/number_input_controller.js +306 -0
  42. data/dist/controllers/otp_controller.js +201 -0
  43. data/dist/controllers/overflow_indicator_controller.js +169 -0
  44. data/dist/controllers/overflow_menu_controller.js +274 -0
  45. data/dist/controllers/pagination_controller.js +89 -0
  46. data/dist/controllers/password_strength_controller.js +175 -0
  47. data/dist/controllers/persist_controller.js +259 -0
  48. data/dist/controllers/popover_controller.js +94 -0
  49. data/dist/controllers/portal_controller.js +63 -0
  50. data/dist/controllers/preview_guard_controller.js +69 -0
  51. data/dist/controllers/progress_controller.js +93 -0
  52. data/dist/controllers/radio_group_controller.js +128 -0
  53. data/dist/controllers/rating_controller.js +179 -0
  54. data/dist/controllers/relative_time_controller.js +129 -0
  55. data/dist/controllers/reset_before_cache_controller.js +62 -0
  56. data/dist/controllers/resizable_controller.js +163 -0
  57. data/dist/controllers/roving_controller.js +116 -0
  58. data/dist/controllers/scroll_area_controller.js +183 -0
  59. data/dist/controllers/scroll_visibility_controller.js +103 -0
  60. data/dist/controllers/scrollspy_controller.js +171 -0
  61. data/dist/controllers/skeleton_controller.js +125 -0
  62. data/dist/controllers/slider_controller.js +109 -0
  63. data/dist/controllers/spinner_controller.js +164 -0
  64. data/dist/controllers/step_indicator_controller.js +55 -0
  65. data/dist/controllers/stepper_controller.js +78 -0
  66. data/dist/controllers/stick_to_bottom_controller.js +100 -0
  67. data/dist/controllers/sticky_observer_controller.js +53 -0
  68. data/dist/controllers/submit_once_controller.js +206 -0
  69. data/dist/controllers/switch_controller.js +50 -0
  70. data/dist/controllers/tabs_controller.js +63 -0
  71. data/dist/controllers/textarea_autosize_controller.js +72 -0
  72. data/dist/controllers/theme_controller.js +154 -0
  73. data/dist/controllers/toast_controller.js +310 -0
  74. data/dist/controllers/toggle_group_controller.js +130 -0
  75. data/dist/controllers/toolbar_controller.js +113 -0
  76. data/dist/controllers/tooltip_controller.js +165 -0
  77. data/dist/controllers/transition_controller.js +203 -0
  78. data/dist/index.js +12241 -0
  79. data/dist/positioning/index.js +145 -0
  80. data/lib/generators/stimeo/install/install_generator.rb +114 -0
  81. data/lib/generators/stimeo/install/templates/stimeo.js +12 -0
  82. data/lib/stimeo/ui/version.rb +10 -0
  83. data/lib/stimeo/ui.rb +19 -0
  84. data/lib/stimeo-ui.rb +4 -0
  85. metadata +152 -0
@@ -0,0 +1,109 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/slider_controller.ts
4
+ var FRACTION_PROPERTY = "--stimeo--slider-fraction";
5
+ var SliderController = class extends Controller {
6
+ static targets = ["track", "thumb"];
7
+ static values = {
8
+ min: { type: Number, default: 0 },
9
+ max: { type: Number, default: 100 },
10
+ step: { type: Number, default: 1 },
11
+ value: { type: Number, default: 0 }
12
+ };
13
+ static actions = ["onKeydown", "onPointerDown"];
14
+ static events = ["change"];
15
+ /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
16
+ #dragAbort = null;
17
+ /** Clamps the initial value and renders the starting position. */
18
+ connect() {
19
+ this.#setValue(this.valueValue, { silent: true });
20
+ }
21
+ /** Cancels any active pointer drag so document listeners never leak. */
22
+ disconnect() {
23
+ this.#dragAbort?.abort();
24
+ this.#dragAbort = null;
25
+ }
26
+ /** Handles keyboard stepping per the APG slider model. */
27
+ onKeydown(event) {
28
+ const big = this.stepValue * 10;
29
+ let next = null;
30
+ switch (event.key) {
31
+ case "ArrowRight":
32
+ case "ArrowUp":
33
+ next = this.valueValue + this.stepValue;
34
+ break;
35
+ case "ArrowLeft":
36
+ case "ArrowDown":
37
+ next = this.valueValue - this.stepValue;
38
+ break;
39
+ case "PageUp":
40
+ next = this.valueValue + big;
41
+ break;
42
+ case "PageDown":
43
+ next = this.valueValue - big;
44
+ break;
45
+ case "Home":
46
+ next = this.minValue;
47
+ break;
48
+ case "End":
49
+ next = this.maxValue;
50
+ break;
51
+ default:
52
+ return;
53
+ }
54
+ event.preventDefault();
55
+ this.#setValue(next);
56
+ }
57
+ /** Begins a pointer drag: sets the value and tracks subsequent movement. */
58
+ onPointerDown(event) {
59
+ if (!this.hasTrackTarget) return;
60
+ event.preventDefault();
61
+ this.#updateFromClientX(event.clientX);
62
+ if (this.hasThumbTarget) this.thumbTarget.focus();
63
+ this.#dragAbort?.abort();
64
+ const abort = new AbortController();
65
+ this.#dragAbort = abort;
66
+ const onMove = (move) => this.#updateFromClientX(move.clientX);
67
+ const onUp = () => {
68
+ abort.abort();
69
+ this.#dragAbort = null;
70
+ };
71
+ document.addEventListener("pointermove", onMove, { signal: abort.signal });
72
+ document.addEventListener("pointerup", onUp, { signal: abort.signal });
73
+ document.addEventListener("pointercancel", onUp, { signal: abort.signal });
74
+ }
75
+ /** Maps a pointer X coordinate to a value using the track's geometry. */
76
+ #updateFromClientX(clientX) {
77
+ const rect = this.trackTarget.getBoundingClientRect();
78
+ if (rect.width === 0) return;
79
+ const fraction = (clientX - rect.left) / rect.width;
80
+ this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));
81
+ }
82
+ /**
83
+ * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and
84
+ * reflects the new state on the thumb's ARIA attributes and the fraction
85
+ * custom property. Dispatches `change` (detail `{ value }`) on a real value
86
+ * change — symmetric with `range-slider` — unless `silent` (the initial
87
+ * connect render, which is not a user edit).
88
+ */
89
+ #setValue(raw, { silent = false } = {}) {
90
+ const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
91
+ const stepped = this.stepValue > 0 ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue : clamped;
92
+ const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));
93
+ const changed = value !== this.valueValue;
94
+ this.valueValue = value;
95
+ if (this.hasThumbTarget) {
96
+ this.thumbTarget.setAttribute("aria-valuemin", String(this.minValue));
97
+ this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
98
+ this.thumbTarget.setAttribute("aria-valuenow", String(value));
99
+ }
100
+ const span = this.maxValue - this.minValue;
101
+ const fraction = span > 0 ? (value - this.minValue) / span : 0;
102
+ this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
103
+ if (changed && !silent) this.dispatch("change", { detail: { value } });
104
+ }
105
+ };
106
+
107
+ export { SliderController };
108
+ //# sourceMappingURL=slider_controller.js.map
109
+ //# sourceMappingURL=slider_controller.js.map
@@ -0,0 +1,164 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/spinner_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/spinner_controller.ts
59
+ var SpinnerController = class extends Controller {
60
+ static targets = ["indicator", "region", "message"];
61
+ static values = {
62
+ delay: { type: Number, default: 0 },
63
+ minDuration: { type: Number, default: 0 }
64
+ };
65
+ static actions = ["start", "stop"];
66
+ static events = ["hide", "show"];
67
+ #timers = new SafeTimeout();
68
+ /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
69
+ #delayTimerId = null;
70
+ /** Pending min-duration hide timer id, or `null` when none is scheduled. */
71
+ #hideTimerId = null;
72
+ /** Epoch ms when the spinner became visible, used to enforce `minDuration`. */
73
+ #shownAt = 0;
74
+ connect() {
75
+ if (!this.element.hasAttribute("data-state")) {
76
+ this.element.setAttribute("data-state", "idle");
77
+ }
78
+ }
79
+ disconnect() {
80
+ this.#timers.clearAll();
81
+ this.#delayTimerId = null;
82
+ this.#hideTimerId = null;
83
+ }
84
+ /** Begins loading. Honors `delay` before the spinner actually appears. */
85
+ start() {
86
+ if (this.#state === "loading") {
87
+ this.#setBusy(true);
88
+ this.#cancelHide();
89
+ return;
90
+ }
91
+ if (this.#state !== "idle") return;
92
+ this.#setBusy(true);
93
+ this.#cancelHide();
94
+ if (this.delayValue > 0) {
95
+ this.element.setAttribute("data-state", "pending");
96
+ this.#delayTimerId = this.#timers.set(() => {
97
+ this.#delayTimerId = null;
98
+ this.#show();
99
+ }, this.delayValue);
100
+ } else {
101
+ this.#show();
102
+ }
103
+ }
104
+ /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
105
+ stop() {
106
+ const state = this.#state;
107
+ if (state === "pending") {
108
+ this.#cancelDelay();
109
+ this.#setBusy(false);
110
+ this.element.setAttribute("data-state", "idle");
111
+ return;
112
+ }
113
+ if (state !== "loading") return;
114
+ this.#setBusy(false);
115
+ const remaining = this.minDurationValue - (Date.now() - this.#shownAt);
116
+ if (remaining > 0) {
117
+ this.#hideTimerId = this.#timers.set(() => {
118
+ this.#hideTimerId = null;
119
+ this.#hide();
120
+ }, remaining);
121
+ } else {
122
+ this.#hide();
123
+ }
124
+ }
125
+ /** Reveals the indicator, marks the moment shown, and announces via the live region. */
126
+ #show() {
127
+ this.#shownAt = Date.now();
128
+ if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
129
+ this.element.setAttribute("data-state", "loading");
130
+ this.dispatch("show", { detail: {} });
131
+ }
132
+ /** Hides the indicator and returns to the idle state. */
133
+ #hide() {
134
+ if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
135
+ this.element.setAttribute("data-state", "idle");
136
+ this.dispatch("hide", { detail: {} });
137
+ }
138
+ /** Reflects busy state onto the controlled region (if present). */
139
+ #setBusy(busy) {
140
+ if (this.hasRegionTarget) {
141
+ this.regionTarget.setAttribute("aria-busy", String(busy));
142
+ }
143
+ }
144
+ #cancelDelay() {
145
+ if (this.#delayTimerId !== null) {
146
+ this.#timers.clear(this.#delayTimerId);
147
+ this.#delayTimerId = null;
148
+ }
149
+ }
150
+ #cancelHide() {
151
+ if (this.#hideTimerId !== null) {
152
+ this.#timers.clear(this.#hideTimerId);
153
+ this.#hideTimerId = null;
154
+ }
155
+ }
156
+ /** Current lifecycle phase as reflected on `data-state`. */
157
+ get #state() {
158
+ return this.element.getAttribute("data-state") ?? "idle";
159
+ }
160
+ };
161
+
162
+ export { SpinnerController };
163
+ //# sourceMappingURL=spinner_controller.js.map
164
+ //# sourceMappingURL=spinner_controller.js.map
@@ -0,0 +1,55 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/step_indicator_controller.ts
4
+ var StepIndicatorController = class extends Controller {
5
+ static targets = ["step"];
6
+ static values = {
7
+ current: { type: Number, default: 0 }
8
+ };
9
+ static actions = ["setCurrent"];
10
+ static events = ["change"];
11
+ /** Renders the initial state from the `current` value. */
12
+ connect() {
13
+ this.#render();
14
+ }
15
+ /**
16
+ * Updates the current step from an external event (`detail.current`, 0-based)
17
+ * and dispatches `change`. Out-of-range indices are clamped to the step set.
18
+ */
19
+ setCurrent(event) {
20
+ const next = event.detail?.current;
21
+ if (typeof next !== "number" || !Number.isFinite(next)) return;
22
+ const clamped = this.#clamp(next);
23
+ if (clamped === this.currentValue) return;
24
+ this.currentValue = clamped;
25
+ this.#render();
26
+ this.dispatch("change", {
27
+ detail: { current: clamped, total: this.stepTargets.length }
28
+ });
29
+ }
30
+ /** Applies `data-state`, `aria-current`, and the progress ratio custom property. */
31
+ #render() {
32
+ const total = this.stepTargets.length;
33
+ const current = this.#clamp(this.currentValue);
34
+ this.stepTargets.forEach((step, index) => {
35
+ step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
36
+ if (index === current) {
37
+ step.setAttribute("aria-current", "step");
38
+ } else {
39
+ step.removeAttribute("aria-current");
40
+ }
41
+ });
42
+ const ratio = total > 1 ? current / (total - 1) : 0;
43
+ this.element.style.setProperty("--stimeo-step-indicator-ratio", String(ratio));
44
+ }
45
+ /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
46
+ #clamp(index) {
47
+ const last = this.stepTargets.length - 1;
48
+ if (last < 0) return 0;
49
+ return Math.min(last, Math.max(0, Math.trunc(index)));
50
+ }
51
+ };
52
+
53
+ export { StepIndicatorController };
54
+ //# sourceMappingURL=step_indicator_controller.js.map
55
+ //# sourceMappingURL=step_indicator_controller.js.map
@@ -0,0 +1,78 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/stepper_controller.ts
4
+ var StepperController = class extends Controller {
5
+ static targets = ["step"];
6
+ static values = {
7
+ index: { type: Number, default: 0 },
8
+ linear: { type: Boolean, default: false }
9
+ };
10
+ static actions = ["goto", "next", "prev"];
11
+ static events = ["change"];
12
+ /** Normalizes an out-of-range initial `index` and renders the initial state. */
13
+ connect() {
14
+ this.indexValue = this.#clampIndex(this.indexValue);
15
+ this.#render();
16
+ }
17
+ /** Advances to the next step (ignored at the last step). */
18
+ next() {
19
+ this.#moveTo(this.indexValue + 1);
20
+ }
21
+ /** Returns to the previous step (ignored at the first step). */
22
+ prev() {
23
+ this.#moveTo(this.indexValue - 1);
24
+ }
25
+ /** Jumps to the step carried in the action's `index` param. */
26
+ goto(event) {
27
+ const target = Number(event.params.index);
28
+ if (!Number.isFinite(target)) return;
29
+ this.#moveTo(target);
30
+ }
31
+ /**
32
+ * Moves the current step to `target` when allowed: in range, not a no-op, and
33
+ * — under `linear` — not skipping more than one step ahead. Re-renders and
34
+ * dispatches `change`.
35
+ */
36
+ #moveTo(target) {
37
+ const total = this.stepTargets.length;
38
+ if (target < 0 || target >= total) return;
39
+ if (target === this.indexValue) return;
40
+ if (this.linearValue && target > this.indexValue + 1) return;
41
+ const previous = this.indexValue;
42
+ this.indexValue = target;
43
+ this.#render();
44
+ this.dispatch("change", {
45
+ detail: { index: target, previous, step: this.stepTargets[target] }
46
+ });
47
+ }
48
+ /**
49
+ * Derives each step's `data-state` and the current button's `aria-current`.
50
+ *
51
+ * `aria-current="step"` is placed on the step's **first** `<button>`; the markup
52
+ * contract assumes one operable button per step. If a step needs multiple
53
+ * buttons, mark the navigational one first (or this would target the wrong one).
54
+ */
55
+ #render() {
56
+ const current = this.indexValue;
57
+ this.stepTargets.forEach((step, index) => {
58
+ step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
59
+ const button = step.querySelector("button");
60
+ if (!button) return;
61
+ if (index === current) {
62
+ button.setAttribute("aria-current", "step");
63
+ } else {
64
+ button.removeAttribute("aria-current");
65
+ }
66
+ });
67
+ }
68
+ /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
69
+ #clampIndex(index) {
70
+ const last = this.stepTargets.length - 1;
71
+ if (last < 0) return 0;
72
+ return Math.min(last, Math.max(0, Math.trunc(index)));
73
+ }
74
+ };
75
+
76
+ export { StepperController };
77
+ //# sourceMappingURL=stepper_controller.js.map
78
+ //# sourceMappingURL=stepper_controller.js.map
@@ -0,0 +1,100 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/stick_to_bottom_controller.ts
4
+ var countElements = (nodes) => {
5
+ let n = 0;
6
+ for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
7
+ return n;
8
+ };
9
+ var StickToBottomController = class extends Controller {
10
+ static targets = ["content"];
11
+ static values = {
12
+ threshold: { type: Number, default: 80 },
13
+ behavior: { type: String, default: "auto" }
14
+ };
15
+ static actions = ["scrollToBottom"];
16
+ static events = ["pin", "new"];
17
+ #observer = null;
18
+ #pinned = false;
19
+ #onScroll = () => this.#updatePinned();
20
+ connect() {
21
+ this.#pinned = this.#isPinned();
22
+ this.#reflectPinned();
23
+ this.element.addEventListener("scroll", this.#onScroll, { passive: true });
24
+ if (typeof MutationObserver !== "undefined") {
25
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
26
+ this.#observer.observe(this.#watched(), { childList: true });
27
+ }
28
+ }
29
+ disconnect() {
30
+ this.element.removeEventListener("scroll", this.#onScroll);
31
+ this.#observer?.disconnect();
32
+ this.#observer = null;
33
+ }
34
+ /** Jumps to the bottom and re-pins (wired to a "new messages" button). */
35
+ scrollToBottom() {
36
+ this.#scrollToBottom();
37
+ this.element.removeAttribute("data-has-new");
38
+ if (!this.#pinned) {
39
+ this.#pinned = true;
40
+ this.element.setAttribute("data-pinned", "true");
41
+ this.dispatch("pin", { detail: { pinned: true } });
42
+ }
43
+ }
44
+ /** Follows appended children while pinned; otherwise flags new content. */
45
+ #onMutations(mutations) {
46
+ let added = 0;
47
+ for (const mutation of mutations) added += countElements(mutation.addedNodes);
48
+ if (added === 0) return;
49
+ if (this.#pinned) {
50
+ this.#scrollToBottom();
51
+ } else {
52
+ this.element.setAttribute("data-has-new", "true");
53
+ this.dispatch("new", { detail: { count: added } });
54
+ }
55
+ }
56
+ /** Recomputes pinned from the scroll position and reflects it on a transition. */
57
+ #updatePinned() {
58
+ const pinned = this.#isPinned();
59
+ if (pinned === this.#pinned) return;
60
+ this.#pinned = pinned;
61
+ this.#reflectPinned();
62
+ this.dispatch("pin", { detail: { pinned } });
63
+ }
64
+ /** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */
65
+ #reflectPinned() {
66
+ if (this.#pinned) {
67
+ this.element.setAttribute("data-pinned", "true");
68
+ this.element.removeAttribute("data-has-new");
69
+ } else {
70
+ this.element.removeAttribute("data-pinned");
71
+ }
72
+ }
73
+ #isPinned() {
74
+ const el = this.element;
75
+ return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
76
+ }
77
+ #scrollToBottom() {
78
+ const top = this.element.scrollHeight;
79
+ if (typeof this.element.scrollTo === "function") {
80
+ this.element.scrollTo({ top, behavior: this.#behavior() });
81
+ } else {
82
+ this.element.scrollTop = top;
83
+ }
84
+ }
85
+ /** The append-watched element: the `content` target, or the container itself. */
86
+ #watched() {
87
+ return this.hasContentTarget ? this.contentTarget : this.element;
88
+ }
89
+ #behavior() {
90
+ if (this.#prefersReducedMotion()) return "auto";
91
+ return this.behaviorValue === "smooth" ? "smooth" : "auto";
92
+ }
93
+ #prefersReducedMotion() {
94
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
95
+ }
96
+ };
97
+
98
+ export { StickToBottomController };
99
+ //# sourceMappingURL=stick_to_bottom_controller.js.map
100
+ //# sourceMappingURL=stick_to_bottom_controller.js.map
@@ -0,0 +1,53 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/sticky_observer_controller.ts
4
+ var StickyObserverController = class extends Controller {
5
+ static targets = ["sentinel", "element"];
6
+ static values = {
7
+ rootSelector: { type: String, default: "" },
8
+ offset: { type: Number, default: 0 }
9
+ };
10
+ static events = ["change"];
11
+ #observer = null;
12
+ /** Guards against a queued callback mutating state after teardown. */
13
+ #active = false;
14
+ /** Last reported stuck state, so `change` fires only on transitions. */
15
+ #stuck = null;
16
+ #onIntersect = (entries) => {
17
+ if (!this.#active) return;
18
+ const entry = entries[entries.length - 1];
19
+ if (!entry) return;
20
+ this.#setStuck(!entry.isIntersecting);
21
+ };
22
+ connect() {
23
+ if (!this.hasSentinelTarget) return;
24
+ this.#active = true;
25
+ if (typeof IntersectionObserver === "undefined") return;
26
+ const root = this.rootSelectorValue ? document.querySelector(this.rootSelectorValue) : null;
27
+ this.#observer = new IntersectionObserver(this.#onIntersect, {
28
+ root,
29
+ rootMargin: `-${this.offsetValue}px 0px 0px 0px`,
30
+ threshold: [0]
31
+ });
32
+ this.#observer.observe(this.sentinelTarget);
33
+ }
34
+ disconnect() {
35
+ this.#active = false;
36
+ this.#observer?.disconnect();
37
+ this.#observer = null;
38
+ this.#stuck = null;
39
+ }
40
+ /** Reflects the stuck state onto the sticky element and emits `change`. */
41
+ #setStuck(next) {
42
+ if (next === this.#stuck) return;
43
+ this.#stuck = next;
44
+ if (this.hasElementTarget) {
45
+ this.elementTarget.setAttribute("data-stuck", next ? "true" : "false");
46
+ }
47
+ this.dispatch("change", { detail: { stuck: next } });
48
+ }
49
+ };
50
+
51
+ export { StickyObserverController };
52
+ //# sourceMappingURL=sticky_observer_controller.js.map
53
+ //# sourceMappingURL=sticky_observer_controller.js.map