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,116 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/roving_controller.ts
4
+
5
+ // src/utils/roving_tabindex.ts
6
+ var RovingTabindex = class {
7
+ /** Returns the current ordered item elements; called on every operation. */
8
+ #getItems;
9
+ /**
10
+ * @param getItems - Returns the current ordered item elements. Called on every
11
+ * operation so the live target list is always used.
12
+ */
13
+ constructor(getItems) {
14
+ this.#getItems = getItems;
15
+ }
16
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
17
+ get activeIndex() {
18
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
19
+ }
20
+ /**
21
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
22
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
23
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
24
+ * "nothing is currently tabbable".
25
+ *
26
+ * @param index - Position of the item to make tabbable.
27
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
28
+ */
29
+ setActive(index, { focus = false } = {}) {
30
+ const items = this.#getItems();
31
+ items.forEach((item, i) => {
32
+ item.tabIndex = i === index ? 0 : -1;
33
+ });
34
+ if (focus) items[index]?.focus();
35
+ }
36
+ };
37
+ function rovingMove(current, length, delta, wrap) {
38
+ if (length === 0) return -1;
39
+ const next = current + delta;
40
+ if (wrap === "wrap") return (next + length) % length;
41
+ return Math.min(length - 1, Math.max(0, next));
42
+ }
43
+
44
+ // src/controllers/roving_controller.ts
45
+ var RovingController = class extends Controller {
46
+ static targets = ["item"];
47
+ static values = {
48
+ orientation: { type: String, default: "horizontal" },
49
+ wrap: { type: Boolean, default: true },
50
+ homeEnd: { type: Boolean, default: true }
51
+ };
52
+ static events = ["change"];
53
+ #roving = new RovingTabindex(() => this.itemTargets);
54
+ connect() {
55
+ const active = this.#roving.activeIndex;
56
+ this.#roving.setActive(active === -1 ? 0 : active);
57
+ this.element.addEventListener("keydown", this.#onKeydown);
58
+ this.element.addEventListener("focusin", this.#onFocusin);
59
+ }
60
+ disconnect() {
61
+ this.element.removeEventListener("keydown", this.#onKeydown);
62
+ this.element.removeEventListener("focusin", this.#onFocusin);
63
+ }
64
+ /** Arrow keys move focus + the tab stop; Home/End jump to the ends. */
65
+ #onKeydown = (event) => {
66
+ const items = this.itemTargets;
67
+ const current = this.#indexOf(event.target);
68
+ if (current === -1) return;
69
+ const length = items.length;
70
+ const wrap = this.wrapValue ? "wrap" : "clamp";
71
+ const orientation = this.orientationValue;
72
+ const horizontal = orientation === "horizontal" || orientation === "both";
73
+ const vertical = orientation === "vertical" || orientation === "both";
74
+ let next;
75
+ if (horizontal && event.key === "ArrowRight" || vertical && event.key === "ArrowDown") {
76
+ next = rovingMove(current, length, 1, wrap);
77
+ } else if (horizontal && event.key === "ArrowLeft" || vertical && event.key === "ArrowUp") {
78
+ next = rovingMove(current, length, -1, wrap);
79
+ } else if (this.homeEndValue && event.key === "Home") {
80
+ next = 0;
81
+ } else if (this.homeEndValue && event.key === "End") {
82
+ next = length - 1;
83
+ } else {
84
+ return;
85
+ }
86
+ event.preventDefault();
87
+ this.#activate(next, true);
88
+ };
89
+ /**
90
+ * Syncs the single tab stop to an item that received focus by other means
91
+ * (click, programmatic `focus()`), so returning via Tab lands on it. The
92
+ * keyboard path's own `focus()` re-enters here but is a no-op (index unchanged).
93
+ */
94
+ #onFocusin = (event) => {
95
+ const index = this.#indexOf(event.target);
96
+ if (index !== -1) this.#activate(index, false);
97
+ };
98
+ /** Resolves the item index owning an event target (the item or a descendant). */
99
+ #indexOf(target) {
100
+ const node = target;
101
+ if (!node) return -1;
102
+ return this.itemTargets.findIndex((item) => item === node || item.contains(node));
103
+ }
104
+ /** Makes `index` the tab stop (optionally focusing it), emitting `change` once. */
105
+ #activate(index, focus) {
106
+ const previous = this.#roving.activeIndex;
107
+ this.#roving.setActive(index, { focus });
108
+ if (index !== previous) {
109
+ this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
110
+ }
111
+ }
112
+ };
113
+
114
+ export { RovingController };
115
+ //# sourceMappingURL=roving_controller.js.map
116
+ //# sourceMappingURL=roving_controller.js.map
@@ -0,0 +1,183 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/scroll_area_controller.ts
4
+
5
+ // src/utils/layout_observer.ts
6
+ var LayoutObserver = class {
7
+ #callback;
8
+ #resizeObserverFactory;
9
+ #resizeObserver = null;
10
+ #observingViewport = false;
11
+ /** Stable bound handler so add/removeEventListener target the same reference. */
12
+ #handleViewportResize = () => {
13
+ this.#callback();
14
+ };
15
+ constructor(callback, options = {}) {
16
+ this.#callback = callback;
17
+ this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
18
+ }
19
+ /**
20
+ * Starts observing an element's size. Repeated calls observe additional
21
+ * elements through the same shared observer. No-ops when no
22
+ * `ResizeObserver` implementation is available.
23
+ */
24
+ observe(element) {
25
+ if (!this.#resizeObserverFactory) return;
26
+ if (!this.#resizeObserver) {
27
+ this.#resizeObserver = this.#resizeObserverFactory(() => {
28
+ this.#callback();
29
+ });
30
+ }
31
+ this.#resizeObserver.observe(element);
32
+ }
33
+ /** Stops observing a single element while leaving any others in place. */
34
+ unobserve(element) {
35
+ this.#resizeObserver?.unobserve(element);
36
+ }
37
+ /** Starts observing viewport resizes. Idempotent: the listener is added once. */
38
+ observeViewport() {
39
+ if (this.#observingViewport) return;
40
+ this.#observingViewport = true;
41
+ window.addEventListener("resize", this.#handleViewportResize);
42
+ }
43
+ /** Stops observing viewport resizes without affecting element observation. */
44
+ unobserveViewport() {
45
+ if (!this.#observingViewport) return;
46
+ this.#observingViewport = false;
47
+ window.removeEventListener("resize", this.#handleViewportResize);
48
+ }
49
+ /**
50
+ * Releases every observation: disconnects the {@link ResizeObserver} and
51
+ * removes the viewport listener. Safe to call multiple times. Call this from a
52
+ * controller's `disconnect()`.
53
+ */
54
+ disconnect() {
55
+ this.#resizeObserver?.disconnect();
56
+ this.#resizeObserver = null;
57
+ this.unobserveViewport();
58
+ }
59
+ };
60
+
61
+ // src/controllers/scroll_area_controller.ts
62
+ var FOCUSABLE_SELECTOR = [
63
+ "a[href]",
64
+ "button:not([disabled])",
65
+ "input:not([disabled])",
66
+ "select:not([disabled])",
67
+ "textarea:not([disabled])",
68
+ "[tabindex]:not([tabindex='-1'])",
69
+ "[contenteditable='true']"
70
+ ].join(",");
71
+ var EDGE_EPSILON = 1;
72
+ var ScrollAreaController = class extends Controller {
73
+ static targets = ["viewport"];
74
+ static values = {
75
+ orientation: { type: String, default: "vertical" }
76
+ };
77
+ static events = ["reach"];
78
+ #layout = new LayoutObserver(() => this.#update());
79
+ /** Last edge reported via `reach`, so the event fires once per arrival. */
80
+ #lastEdge = null;
81
+ /** Whether this controller added `tabindex`, so teardown only removes its own. */
82
+ #addedTabindex = false;
83
+ /** Whether this controller added `role="region"`, for symmetric teardown. */
84
+ #addedRole = false;
85
+ #onScroll = () => {
86
+ this.#update();
87
+ };
88
+ connect() {
89
+ if (!this.hasViewportTarget) return;
90
+ this.viewportTarget.addEventListener("scroll", this.#onScroll, { passive: true });
91
+ this.#layout.observe(this.viewportTarget);
92
+ this.#layout.observeViewport();
93
+ this.#update();
94
+ }
95
+ disconnect() {
96
+ if (this.hasViewportTarget) {
97
+ this.viewportTarget.removeEventListener("scroll", this.#onScroll);
98
+ this.#clearAddedAttributes(this.viewportTarget);
99
+ }
100
+ this.#layout.disconnect();
101
+ this.#lastEdge = null;
102
+ }
103
+ /** Re-measures overflow and scroll position and reflects the state hooks. */
104
+ #update() {
105
+ if (!this.hasViewportTarget) return;
106
+ const vp = this.viewportTarget;
107
+ const overflowing = this.#measureOverflow(vp);
108
+ this.element.setAttribute("data-overflow", overflowing ? "true" : "false");
109
+ this.#syncKeyboardReach(vp, overflowing);
110
+ const { position, progress } = this.#measurePosition(vp);
111
+ this.element.setAttribute("data-scroll", position);
112
+ this.element.style.setProperty("--stimeo-scroll-progress", String(progress));
113
+ const edge = position === "start" ? "start" : position === "end" ? "end" : null;
114
+ if (overflowing && edge && edge !== this.#lastEdge) {
115
+ this.#lastEdge = edge;
116
+ this.dispatch("reach", { detail: { edge } });
117
+ } else if (!edge) {
118
+ this.#lastEdge = null;
119
+ }
120
+ }
121
+ /** Whether the viewport can scroll on the configured axis. */
122
+ #measureOverflow(vp) {
123
+ const o = this.orientationValue;
124
+ const vertical = o !== "horizontal" && vp.scrollHeight > vp.clientHeight + EDGE_EPSILON;
125
+ const horizontal = o !== "vertical" && vp.scrollWidth > vp.clientWidth + EDGE_EPSILON;
126
+ return vertical || horizontal;
127
+ }
128
+ /**
129
+ * Reports the scroll position bucket and 0–1 progress on the primary axis. For
130
+ * `both`, the vertical axis is used when it overflows, otherwise the horizontal.
131
+ */
132
+ #measurePosition(vp) {
133
+ const horizontalPrimary = this.orientationValue === "horizontal" || this.orientationValue === "both" && vp.scrollHeight <= vp.clientHeight + EDGE_EPSILON;
134
+ const scrollPos = horizontalPrimary ? vp.scrollLeft : vp.scrollTop;
135
+ const maxScroll = horizontalPrimary ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
136
+ if (maxScroll <= EDGE_EPSILON) return { position: "start", progress: 0 };
137
+ const progress = Math.min(1, Math.max(0, scrollPos / maxScroll));
138
+ if (scrollPos <= EDGE_EPSILON) return { position: "start", progress };
139
+ if (scrollPos >= maxScroll - EDGE_EPSILON) return { position: "end", progress };
140
+ return { position: "middle", progress };
141
+ }
142
+ /**
143
+ * Makes the viewport keyboard-scrollable when it overflows and contains no
144
+ * focusable elements of its own (avoiding a double tab stop). Adds `role="region"`
145
+ * only when the viewport already carries an accessible name.
146
+ */
147
+ #syncKeyboardReach(vp, overflowing) {
148
+ const wantsTabindex = overflowing && !this.#hasFocusableContent(vp);
149
+ if (wantsTabindex) {
150
+ if (!vp.hasAttribute("tabindex")) {
151
+ vp.setAttribute("tabindex", "0");
152
+ this.#addedTabindex = true;
153
+ }
154
+ if (!vp.hasAttribute("role") && this.#hasAccessibleName(vp)) {
155
+ vp.setAttribute("role", "region");
156
+ this.#addedRole = true;
157
+ }
158
+ } else {
159
+ this.#clearAddedAttributes(vp);
160
+ }
161
+ }
162
+ /** Removes (and resets the flags for) only the attributes this controller added. */
163
+ #clearAddedAttributes(vp) {
164
+ if (this.#addedTabindex) {
165
+ vp.removeAttribute("tabindex");
166
+ this.#addedTabindex = false;
167
+ }
168
+ if (this.#addedRole) {
169
+ vp.removeAttribute("role");
170
+ this.#addedRole = false;
171
+ }
172
+ }
173
+ #hasFocusableContent(vp) {
174
+ return vp.querySelector(FOCUSABLE_SELECTOR) !== null;
175
+ }
176
+ #hasAccessibleName(vp) {
177
+ return vp.hasAttribute("aria-label") || vp.hasAttribute("aria-labelledby");
178
+ }
179
+ };
180
+
181
+ export { ScrollAreaController };
182
+ //# sourceMappingURL=scroll_area_controller.js.map
183
+ //# sourceMappingURL=scroll_area_controller.js.map
@@ -0,0 +1,103 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/scroll_visibility_controller.ts
4
+ var ScrollVisibilityController = class extends Controller {
5
+ static targets = ["element"];
6
+ static values = {
7
+ offset: { type: Number, default: 400 },
8
+ mode: { type: String, default: "offset" },
9
+ focusSelector: { type: String, default: "" },
10
+ root: { type: String, default: "" }
11
+ };
12
+ static actions = ["toTop"];
13
+ static events = ["change"];
14
+ /** Pending rAF id used to coalesce scroll bursts into one measurement. */
15
+ #rafId = null;
16
+ /** Previous scroll position, for `direction` mode delta detection. */
17
+ #lastScrollY = 0;
18
+ /** Current visibility, tracked to dispatch `change` only on real transitions. */
19
+ #visible = null;
20
+ /**
21
+ * The observed scroll source: a container element when `root` resolves, else
22
+ * the window. Captured on connect so teardown detaches from the same source.
23
+ */
24
+ #scrollSource = window;
25
+ #onScroll = () => {
26
+ if (this.#rafId !== null) return;
27
+ this.#rafId = requestAnimationFrame(() => {
28
+ this.#rafId = null;
29
+ this.#evaluate();
30
+ });
31
+ };
32
+ connect() {
33
+ this.#scrollSource = this.#resolveScrollSource();
34
+ this.#lastScrollY = this.#scrollY();
35
+ this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
36
+ this.#evaluate();
37
+ }
38
+ disconnect() {
39
+ this.#scrollSource.removeEventListener("scroll", this.#onScroll);
40
+ if (this.#rafId !== null) {
41
+ cancelAnimationFrame(this.#rafId);
42
+ this.#rafId = null;
43
+ }
44
+ this.#visible = null;
45
+ }
46
+ /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
47
+ toTop() {
48
+ const behavior = this.#prefersReducedMotion() ? "auto" : "smooth";
49
+ this.#scrollSource.scrollTo({ top: 0, behavior });
50
+ if (this.focusSelectorValue) {
51
+ const target = document.querySelector(this.focusSelectorValue);
52
+ if (target) {
53
+ if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
54
+ target.focus();
55
+ }
56
+ }
57
+ }
58
+ /** Decides the next visibility from the current scroll state and applies it. */
59
+ #evaluate() {
60
+ const y = this.#scrollY();
61
+ let nextVisible;
62
+ if (this.modeValue === "direction") {
63
+ if (y <= this.offsetValue) {
64
+ nextVisible = true;
65
+ } else {
66
+ nextVisible = y < this.#lastScrollY;
67
+ }
68
+ } else {
69
+ nextVisible = y > this.offsetValue;
70
+ }
71
+ this.#lastScrollY = y;
72
+ this.#setVisible(nextVisible);
73
+ }
74
+ /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
75
+ #setVisible(next) {
76
+ if (next === this.#visible) return;
77
+ this.#visible = next;
78
+ if (this.hasElementTarget) this.elementTarget.hidden = !next;
79
+ this.element.setAttribute("data-state", next ? "visible" : "hidden");
80
+ this.dispatch("change", { detail: { visible: next } });
81
+ }
82
+ /** Resolves the scroll source from `root` (falling back to the window). */
83
+ #resolveScrollSource() {
84
+ if (this.rootValue) {
85
+ const root = document.querySelector(this.rootValue);
86
+ if (root) return root;
87
+ }
88
+ return window;
89
+ }
90
+ #scrollY() {
91
+ if (this.#scrollSource === window) {
92
+ return window.scrollY ?? window.pageYOffset ?? 0;
93
+ }
94
+ return this.#scrollSource.scrollTop;
95
+ }
96
+ #prefersReducedMotion() {
97
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
98
+ }
99
+ };
100
+
101
+ export { ScrollVisibilityController };
102
+ //# sourceMappingURL=scroll_visibility_controller.js.map
103
+ //# sourceMappingURL=scroll_visibility_controller.js.map
@@ -0,0 +1,171 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/scrollspy_controller.ts
4
+ var ScrollspyController = class extends Controller {
5
+ static targets = ["link"];
6
+ static values = {
7
+ offset: { type: Number, default: 0 },
8
+ rootMargin: { type: String, default: "" },
9
+ rootSelector: { type: String, default: "" }
10
+ };
11
+ static actions = ["scrollTo"];
12
+ static events = ["change"];
13
+ #observer = null;
14
+ #isConnected = false;
15
+ /** Track active/intersecting status of each section element by ID. */
16
+ #intersectionStates = /* @__PURE__ */ new Map();
17
+ /** Current active section ID, used to avoid duplicate event dispatching. */
18
+ #activeSectionId = "";
19
+ connect() {
20
+ this.#isConnected = true;
21
+ this.#initializeObserver();
22
+ }
23
+ disconnect() {
24
+ this.#isConnected = false;
25
+ if (this.#observer) {
26
+ this.#observer.disconnect();
27
+ this.#observer = null;
28
+ }
29
+ this.#intersectionStates.clear();
30
+ this.#activeSectionId = "";
31
+ }
32
+ /**
33
+ * Re-initializes the observer if the offset or rootMargin values change dynamically.
34
+ */
35
+ offsetValueChanged() {
36
+ if (!this.#isConnected) return;
37
+ this.#initializeObserver();
38
+ }
39
+ rootMarginValueChanged() {
40
+ if (!this.#isConnected) return;
41
+ this.#initializeObserver();
42
+ }
43
+ rootSelectorValueChanged() {
44
+ if (!this.#isConnected) return;
45
+ this.#initializeObserver();
46
+ }
47
+ /**
48
+ * Smoothly scrolls to the target element mapped by the link anchor.
49
+ * Prevents full window scroll jumps when tracking nested scrollable containers.
50
+ */
51
+ scrollTo(event) {
52
+ const link = event.currentTarget;
53
+ const id = this.#getAnchorId(link);
54
+ if (!id) return;
55
+ event.preventDefault();
56
+ const targetElement = document.getElementById(id);
57
+ if (!targetElement) return;
58
+ const rootElement = this.#getRootElement();
59
+ if (rootElement) {
60
+ const containerRect = rootElement.getBoundingClientRect();
61
+ const targetRect = targetElement.getBoundingClientRect();
62
+ const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - this.offsetValue;
63
+ rootElement.scrollTo({
64
+ top: scrollPosition,
65
+ behavior: "smooth"
66
+ });
67
+ } else {
68
+ const targetRect = targetElement.getBoundingClientRect();
69
+ const scrollPosition = window.scrollY + targetRect.top - this.offsetValue;
70
+ window.scrollTo({
71
+ top: scrollPosition,
72
+ behavior: "smooth"
73
+ });
74
+ }
75
+ }
76
+ #getRootElement() {
77
+ if (!this.rootSelectorValue) return null;
78
+ return document.querySelector(this.rootSelectorValue);
79
+ }
80
+ #initializeObserver() {
81
+ if (this.#observer) {
82
+ this.#observer.disconnect();
83
+ this.#observer = null;
84
+ }
85
+ this.#intersectionStates.clear();
86
+ this.#activeSectionId = "";
87
+ if (this.linkTargets.length === 0) return;
88
+ const margin = this.rootMarginValue || `-${this.offsetValue}px 0px -80% 0px`;
89
+ const rootEl = this.#getRootElement();
90
+ this.#observer = new IntersectionObserver(this.#onIntersection, {
91
+ root: rootEl,
92
+ rootMargin: margin,
93
+ threshold: [0, 0.2, 0.4, 0.6, 0.8, 1]
94
+ // Multiple thresholds handle large sections safely
95
+ });
96
+ for (const link of this.linkTargets) {
97
+ const id = this.#getAnchorId(link);
98
+ if (!id) continue;
99
+ const section = document.getElementById(id);
100
+ if (section) {
101
+ this.#observer.observe(section);
102
+ }
103
+ }
104
+ }
105
+ #onIntersection = (entries) => {
106
+ if (!this.#isConnected) return;
107
+ for (const entry of entries) {
108
+ const id = entry.target.id;
109
+ if (!id) continue;
110
+ this.#intersectionStates.set(id, {
111
+ isIntersecting: entry.isIntersecting,
112
+ top: entry.boundingClientRect.top
113
+ });
114
+ }
115
+ this.#evaluateActiveSection();
116
+ };
117
+ #evaluateActiveSection() {
118
+ const rootEl = this.#getRootElement();
119
+ const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this.offsetValue;
120
+ let bestId = "";
121
+ let closestTop = Number.MAX_VALUE;
122
+ for (const [id, state] of this.#intersectionStates.entries()) {
123
+ if (state.isIntersecting) {
124
+ const distance = Math.abs(state.top - triggerLine);
125
+ if (distance < closestTop) {
126
+ closestTop = distance;
127
+ bestId = id;
128
+ }
129
+ }
130
+ }
131
+ if (!bestId && this.#intersectionStates.size > 0) {
132
+ let absoluteClosestId = "";
133
+ let absoluteClosestTop = Number.MAX_VALUE;
134
+ for (const [id, state] of this.#intersectionStates.entries()) {
135
+ const distance = Math.abs(state.top - triggerLine);
136
+ if (distance < absoluteClosestTop) {
137
+ absoluteClosestTop = distance;
138
+ absoluteClosestId = id;
139
+ }
140
+ }
141
+ bestId = absoluteClosestId;
142
+ }
143
+ if (bestId && bestId !== this.#activeSectionId) {
144
+ this.#activeSectionId = bestId;
145
+ this.#syncActiveStates();
146
+ }
147
+ }
148
+ #syncActiveStates() {
149
+ const activeLink = this.linkTargets.find((l) => this.#getAnchorId(l) === this.#activeSectionId);
150
+ for (const link of this.linkTargets) {
151
+ const isActive = link === activeLink;
152
+ if (isActive) {
153
+ link.setAttribute("aria-current", "location");
154
+ } else {
155
+ link.removeAttribute("aria-current");
156
+ }
157
+ }
158
+ if (activeLink) {
159
+ this.dispatch("change", { detail: { id: this.#activeSectionId, link: activeLink } });
160
+ }
161
+ }
162
+ #getAnchorId(link) {
163
+ const href = link.getAttribute("href") || link.getAttribute("data-href");
164
+ if (!href?.startsWith("#")) return null;
165
+ return href.substring(1);
166
+ }
167
+ };
168
+
169
+ export { ScrollspyController };
170
+ //# sourceMappingURL=scrollspy_controller.js.map
171
+ //# sourceMappingURL=scrollspy_controller.js.map
@@ -0,0 +1,125 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/skeleton_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/skeleton_controller.ts
59
+ var SkeletonController = class extends Controller {
60
+ static targets = ["placeholder", "content"];
61
+ static values = {
62
+ minDuration: { type: Number, default: 0 }
63
+ };
64
+ static actions = ["ready", "reset"];
65
+ static events = ["ready"];
66
+ #timers = new SafeTimeout();
67
+ /** Pending min-duration reveal timer id, or `null` when none is scheduled. */
68
+ #revealTimerId = null;
69
+ /** Epoch ms when the loading state began, used to enforce `minDuration`. */
70
+ #loadingSince = 0;
71
+ connect() {
72
+ if (this.#state !== "ready") {
73
+ this.#enterLoading();
74
+ }
75
+ }
76
+ disconnect() {
77
+ this.#timers.clearAll();
78
+ this.#revealTimerId = null;
79
+ }
80
+ /** Swaps to the real content. Honors `minDuration` to prevent a flash. */
81
+ ready() {
82
+ if (this.#state === "ready" || this.#revealTimerId !== null) return;
83
+ const remaining = this.minDurationValue - (Date.now() - this.#loadingSince);
84
+ if (remaining > 0) {
85
+ this.#revealTimerId = this.#timers.set(() => {
86
+ this.#revealTimerId = null;
87
+ this.#reveal();
88
+ }, remaining);
89
+ } else {
90
+ this.#reveal();
91
+ }
92
+ }
93
+ /** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
94
+ reset() {
95
+ if (this.#revealTimerId !== null) {
96
+ this.#timers.clear(this.#revealTimerId);
97
+ this.#revealTimerId = null;
98
+ }
99
+ this.#enterLoading();
100
+ }
101
+ /** Shows the placeholder, hides content, and marks the region busy. */
102
+ #enterLoading() {
103
+ this.#loadingSince = Date.now();
104
+ if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;
105
+ if (this.hasContentTarget) this.contentTarget.hidden = true;
106
+ this.element.setAttribute("aria-busy", "true");
107
+ this.element.setAttribute("data-state", "loading");
108
+ }
109
+ /** Hides the placeholder, shows content, and clears the busy state. */
110
+ #reveal() {
111
+ if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = true;
112
+ if (this.hasContentTarget) this.contentTarget.hidden = false;
113
+ this.element.setAttribute("aria-busy", "false");
114
+ this.element.setAttribute("data-state", "ready");
115
+ this.dispatch("ready", { detail: {} });
116
+ }
117
+ /** Current lifecycle phase as reflected on `data-state`. */
118
+ get #state() {
119
+ return this.element.getAttribute("data-state") ?? "loading";
120
+ }
121
+ };
122
+
123
+ export { SkeletonController };
124
+ //# sourceMappingURL=skeleton_controller.js.map
125
+ //# sourceMappingURL=skeleton_controller.js.map