stimeo-ui 0.2.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +213 -0
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +168 -0
- data/dist/controllers/date_range_picker_controller.js +417 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +168 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menubar_controller.js +433 -0
- data/dist/controllers/multi_select_controller.js +472 -0
- data/dist/controllers/navigation_menu_controller.js +384 -0
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/range_slider_controller.js +166 -0
- data/dist/controllers/read_more_controller.js +194 -0
- data/dist/controllers/scroll_area_controller.js +15 -2
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +8 -4
- data/dist/controllers/scrollspy_controller.js +33 -11
- data/dist/controllers/separator_controller.js +87 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +8 -4
- data/dist/controllers/sticky_observer_controller.js +88 -20
- data/dist/controllers/tags_input_controller.js +275 -0
- data/dist/controllers/theme_controller.js +20 -10
- data/dist/controllers/time_picker_controller.js +212 -0
- data/dist/controllers/toast_controller.js +36 -9
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +811 -295
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +28 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/password_reveal_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/password_reveal_controller.ts
|
|
59
|
+
var PasswordRevealController = class extends Controller {
|
|
60
|
+
static targets = ["input", "toggle"];
|
|
61
|
+
static values = {
|
|
62
|
+
autoHide: { type: Number, default: 0 }
|
|
63
|
+
};
|
|
64
|
+
static actions = ["toggle"];
|
|
65
|
+
static events = ["toggle"];
|
|
66
|
+
/** Auto re-mask timer; torn down on disconnect. */
|
|
67
|
+
#timers = new SafeTimeout();
|
|
68
|
+
connect() {
|
|
69
|
+
this.#reflect(this.#isVisible);
|
|
70
|
+
}
|
|
71
|
+
disconnect() {
|
|
72
|
+
this.#timers.clearAll();
|
|
73
|
+
}
|
|
74
|
+
/** Toggles the input between masked and revealed. Bound via `data-action`. */
|
|
75
|
+
toggle() {
|
|
76
|
+
this.#setVisible(!this.#isVisible);
|
|
77
|
+
}
|
|
78
|
+
/** Whether the input is currently revealed (`type="text"`). */
|
|
79
|
+
get #isVisible() {
|
|
80
|
+
return this.hasInputTarget && this.inputTarget.type === "text";
|
|
81
|
+
}
|
|
82
|
+
/** Switches the masked/revealed state, preserving focus and caret. */
|
|
83
|
+
#setVisible(visible) {
|
|
84
|
+
if (!this.hasInputTarget) return;
|
|
85
|
+
const input = this.inputTarget;
|
|
86
|
+
const restoreInputFocus = document.activeElement === input;
|
|
87
|
+
const selectionStart = input.selectionStart;
|
|
88
|
+
const selectionEnd = input.selectionEnd;
|
|
89
|
+
input.type = visible ? "text" : "password";
|
|
90
|
+
if (restoreInputFocus) {
|
|
91
|
+
input.focus();
|
|
92
|
+
if (selectionStart !== null && selectionEnd !== null) {
|
|
93
|
+
try {
|
|
94
|
+
input.setSelectionRange(selectionStart, selectionEnd);
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.#reflect(visible);
|
|
100
|
+
this.dispatch("toggle", { detail: { visible } });
|
|
101
|
+
this.#timers.clearAll();
|
|
102
|
+
if (visible && this.autoHideValue > 0) {
|
|
103
|
+
this.#timers.set(() => this.#setVisible(false), this.autoHideValue);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Reflects the visible state onto `aria-pressed` and `data-state`. */
|
|
107
|
+
#reflect(visible) {
|
|
108
|
+
if (this.hasToggleTarget) {
|
|
109
|
+
this.toggleTarget.setAttribute("aria-pressed", visible ? "true" : "false");
|
|
110
|
+
}
|
|
111
|
+
this.element.setAttribute("data-state", visible ? "visible" : "hidden");
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export { PasswordRevealController };
|
|
116
|
+
//# sourceMappingURL=password_reveal_controller.js.map
|
|
117
|
+
//# sourceMappingURL=password_reveal_controller.js.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/range_slider_controller.ts
|
|
4
|
+
var START_PROPERTY = "--stimeo-range-start";
|
|
5
|
+
var END_PROPERTY = "--stimeo-range-end";
|
|
6
|
+
var RangeSliderController = class extends Controller {
|
|
7
|
+
static targets = ["track", "startThumb", "endThumb"];
|
|
8
|
+
static values = {
|
|
9
|
+
min: { type: Number, default: 0 },
|
|
10
|
+
max: { type: Number, default: 100 },
|
|
11
|
+
step: { type: Number, default: 1 },
|
|
12
|
+
start: { type: Number, default: 0 },
|
|
13
|
+
end: { type: Number, default: 100 }
|
|
14
|
+
};
|
|
15
|
+
static actions = ["onKeydown", "onPointerDown"];
|
|
16
|
+
static events = ["change"];
|
|
17
|
+
/** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
|
|
18
|
+
#dragAbort = null;
|
|
19
|
+
/** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
|
|
20
|
+
connect() {
|
|
21
|
+
const lo = Math.min(this.startValue, this.endValue);
|
|
22
|
+
const hi = Math.max(this.startValue, this.endValue);
|
|
23
|
+
this.#commit(lo, hi, false);
|
|
24
|
+
}
|
|
25
|
+
/** Cancels any active pointer drag so document listeners never leak. */
|
|
26
|
+
disconnect() {
|
|
27
|
+
this.#dragAbort?.abort();
|
|
28
|
+
this.#dragAbort = null;
|
|
29
|
+
}
|
|
30
|
+
/** Keyboard stepping for whichever thumb is focused (the action's element). */
|
|
31
|
+
onKeydown(event) {
|
|
32
|
+
const thumb = event.currentTarget;
|
|
33
|
+
const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;
|
|
34
|
+
const current = isStart ? this.startValue : this.endValue;
|
|
35
|
+
const lower = isStart ? this.minValue : this.startValue;
|
|
36
|
+
const upper = isStart ? this.endValue : this.maxValue;
|
|
37
|
+
const big = this.stepValue * 10;
|
|
38
|
+
let next = null;
|
|
39
|
+
switch (event.key) {
|
|
40
|
+
case "ArrowRight":
|
|
41
|
+
case "ArrowUp":
|
|
42
|
+
next = current + this.stepValue;
|
|
43
|
+
break;
|
|
44
|
+
case "ArrowLeft":
|
|
45
|
+
case "ArrowDown":
|
|
46
|
+
next = current - this.stepValue;
|
|
47
|
+
break;
|
|
48
|
+
case "PageUp":
|
|
49
|
+
next = current + big;
|
|
50
|
+
break;
|
|
51
|
+
case "PageDown":
|
|
52
|
+
next = current - big;
|
|
53
|
+
break;
|
|
54
|
+
case "Home":
|
|
55
|
+
next = lower;
|
|
56
|
+
break;
|
|
57
|
+
case "End":
|
|
58
|
+
next = upper;
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
this.#moveThumb(isStart, next);
|
|
65
|
+
}
|
|
66
|
+
/** Begins a pointer drag on the track, moving the thumb nearest the press. */
|
|
67
|
+
onPointerDown(event) {
|
|
68
|
+
if (!this.hasTrackTarget) return;
|
|
69
|
+
const value = this.#valueFromClientX(event.clientX);
|
|
70
|
+
if (value === null) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);
|
|
73
|
+
if (useStart) {
|
|
74
|
+
if (this.hasStartThumbTarget) this.startThumbTarget.focus();
|
|
75
|
+
} else if (this.hasEndThumbTarget) {
|
|
76
|
+
this.endThumbTarget.focus();
|
|
77
|
+
}
|
|
78
|
+
this.#moveThumb(useStart, value);
|
|
79
|
+
this.#dragAbort?.abort();
|
|
80
|
+
const abort = new AbortController();
|
|
81
|
+
this.#dragAbort = abort;
|
|
82
|
+
const onMove = (move) => {
|
|
83
|
+
const moved = this.#valueFromClientX(move.clientX);
|
|
84
|
+
if (moved !== null) this.#moveThumb(useStart, moved);
|
|
85
|
+
};
|
|
86
|
+
const onUp = () => {
|
|
87
|
+
abort.abort();
|
|
88
|
+
this.#dragAbort = null;
|
|
89
|
+
};
|
|
90
|
+
document.addEventListener("pointermove", onMove, { signal: abort.signal });
|
|
91
|
+
document.addEventListener("pointerup", onUp, { signal: abort.signal });
|
|
92
|
+
document.addEventListener("pointercancel", onUp, { signal: abort.signal });
|
|
93
|
+
}
|
|
94
|
+
/** Maps a pointer X coordinate to a raw value using the track geometry. */
|
|
95
|
+
#valueFromClientX(clientX) {
|
|
96
|
+
const rect = this.trackTarget.getBoundingClientRect();
|
|
97
|
+
if (rect.width === 0) return null;
|
|
98
|
+
const fraction = (clientX - rect.left) / rect.width;
|
|
99
|
+
return this.minValue + fraction * (this.maxValue - this.minValue);
|
|
100
|
+
}
|
|
101
|
+
/** Moves one thumb to a new raw value, keeping the pair ordered. */
|
|
102
|
+
#moveThumb(isStart, raw) {
|
|
103
|
+
if (isStart) {
|
|
104
|
+
this.#commit(raw, this.endValue, true);
|
|
105
|
+
} else {
|
|
106
|
+
this.#commit(this.startValue, raw, true);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and
|
|
111
|
+
* reflects it onto the thumbs' ARIA attributes and the range custom
|
|
112
|
+
* properties. Dispatches `change` only on user-driven updates (`notify`).
|
|
113
|
+
*/
|
|
114
|
+
#commit(start, end, notify) {
|
|
115
|
+
const prevStart = this.startValue;
|
|
116
|
+
const prevEnd = this.endValue;
|
|
117
|
+
let nextStart = this.#snap(start);
|
|
118
|
+
let nextEnd = this.#snap(end);
|
|
119
|
+
if (nextStart > nextEnd) {
|
|
120
|
+
if (isUserMovingStart(start, prevStart, end, prevEnd)) nextStart = nextEnd;
|
|
121
|
+
else nextEnd = nextStart;
|
|
122
|
+
}
|
|
123
|
+
this.startValue = nextStart;
|
|
124
|
+
this.endValue = nextEnd;
|
|
125
|
+
this.#render(nextStart, nextEnd);
|
|
126
|
+
if (notify && (nextStart !== prevStart || nextEnd !== prevEnd)) {
|
|
127
|
+
this.dispatch("change", { detail: { start: nextStart, end: nextEnd } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Reflects the current pair onto thumb ARIA attributes and CSS properties. */
|
|
131
|
+
#render(start, end) {
|
|
132
|
+
if (this.hasStartThumbTarget) {
|
|
133
|
+
this.startThumbTarget.setAttribute("aria-valuemin", String(this.minValue));
|
|
134
|
+
this.startThumbTarget.setAttribute("aria-valuemax", String(end));
|
|
135
|
+
this.startThumbTarget.setAttribute("aria-valuenow", String(start));
|
|
136
|
+
}
|
|
137
|
+
if (this.hasEndThumbTarget) {
|
|
138
|
+
this.endThumbTarget.setAttribute("aria-valuemin", String(start));
|
|
139
|
+
this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
|
|
140
|
+
this.endThumbTarget.setAttribute("aria-valuenow", String(end));
|
|
141
|
+
}
|
|
142
|
+
const span = this.maxValue - this.minValue;
|
|
143
|
+
this.element.style.setProperty(
|
|
144
|
+
START_PROPERTY,
|
|
145
|
+
String(span > 0 ? (start - this.minValue) / span : 0)
|
|
146
|
+
);
|
|
147
|
+
this.element.style.setProperty(
|
|
148
|
+
END_PROPERTY,
|
|
149
|
+
String(span > 0 ? (end - this.minValue) / span : 0)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
/** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
|
|
153
|
+
#snap(raw) {
|
|
154
|
+
const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
|
|
155
|
+
if (this.stepValue <= 0) return clamped;
|
|
156
|
+
const stepped = Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue;
|
|
157
|
+
return Math.min(this.maxValue, Math.max(this.minValue, stepped));
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
function isUserMovingStart(start, prevStart, end, prevEnd) {
|
|
161
|
+
return start !== prevStart && end === prevEnd;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { RangeSliderController };
|
|
165
|
+
//# sourceMappingURL=range_slider_controller.js.map
|
|
166
|
+
//# sourceMappingURL=range_slider_controller.js.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/read_more_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/read_more_controller.ts
|
|
62
|
+
var ReadMoreController = class extends Controller {
|
|
63
|
+
static targets = ["content", "trigger"];
|
|
64
|
+
static values = {
|
|
65
|
+
collapsed: { type: Boolean, default: true }
|
|
66
|
+
};
|
|
67
|
+
static actions = ["toggle"];
|
|
68
|
+
#connected = false;
|
|
69
|
+
#collapsed = true;
|
|
70
|
+
#observedContent = null;
|
|
71
|
+
#contentMutationObserver = null;
|
|
72
|
+
#deferredHideTrigger = null;
|
|
73
|
+
#update = () => {
|
|
74
|
+
if (this.#connected) this.#evaluateOverflow();
|
|
75
|
+
};
|
|
76
|
+
#layout = new LayoutObserver(this.#update);
|
|
77
|
+
#onDeferredHideBlur = () => {
|
|
78
|
+
this.#clearDeferredHide();
|
|
79
|
+
this.#update();
|
|
80
|
+
};
|
|
81
|
+
connect() {
|
|
82
|
+
this.#connected = true;
|
|
83
|
+
this.#collapsed = this.#initialCollapsed();
|
|
84
|
+
this.#syncTargets();
|
|
85
|
+
}
|
|
86
|
+
disconnect() {
|
|
87
|
+
this.#connected = false;
|
|
88
|
+
this.#stopObservingContent();
|
|
89
|
+
this.#layout.disconnect();
|
|
90
|
+
}
|
|
91
|
+
contentTargetConnected() {
|
|
92
|
+
this.#syncTargets();
|
|
93
|
+
}
|
|
94
|
+
contentTargetDisconnected() {
|
|
95
|
+
this.#syncTargets();
|
|
96
|
+
}
|
|
97
|
+
triggerTargetConnected() {
|
|
98
|
+
this.#syncTargets();
|
|
99
|
+
}
|
|
100
|
+
triggerTargetDisconnected(trigger) {
|
|
101
|
+
if (this.#deferredHideTrigger === trigger) this.#clearDeferredHide();
|
|
102
|
+
this.#syncTargets();
|
|
103
|
+
}
|
|
104
|
+
/** Toggles between the collapsed (clamped) and expanded states. */
|
|
105
|
+
toggle() {
|
|
106
|
+
if (!this.#connected) return;
|
|
107
|
+
this.#collapsed = !this.#collapsed;
|
|
108
|
+
this.#reflect();
|
|
109
|
+
this.#evaluateOverflow();
|
|
110
|
+
}
|
|
111
|
+
#initialCollapsed() {
|
|
112
|
+
if (this.hasContentTarget) {
|
|
113
|
+
const state = this.contentTarget.getAttribute("data-state");
|
|
114
|
+
if (state === "expanded") return false;
|
|
115
|
+
if (state === "collapsed") return true;
|
|
116
|
+
}
|
|
117
|
+
return this.collapsedValue;
|
|
118
|
+
}
|
|
119
|
+
#reflect() {
|
|
120
|
+
if (this.hasContentTarget) {
|
|
121
|
+
this.contentTarget.setAttribute("data-state", this.#collapsed ? "collapsed" : "expanded");
|
|
122
|
+
}
|
|
123
|
+
if (this.hasTriggerTarget) {
|
|
124
|
+
this.triggerTarget.setAttribute("aria-expanded", this.#collapsed ? "false" : "true");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
#syncTargets() {
|
|
128
|
+
if (!this.#connected) return;
|
|
129
|
+
this.#syncContentObservation();
|
|
130
|
+
this.#reflect();
|
|
131
|
+
this.#evaluateOverflow();
|
|
132
|
+
}
|
|
133
|
+
#syncContentObservation() {
|
|
134
|
+
const next = this.hasContentTarget ? this.contentTarget : null;
|
|
135
|
+
if (next === this.#observedContent) return;
|
|
136
|
+
this.#stopObservingContent();
|
|
137
|
+
if (!next) return;
|
|
138
|
+
this.#observedContent = next;
|
|
139
|
+
this.#layout.observe(next);
|
|
140
|
+
this.#layout.observeViewport();
|
|
141
|
+
next.addEventListener("load", this.#update, true);
|
|
142
|
+
if (typeof MutationObserver !== "undefined") {
|
|
143
|
+
this.#contentMutationObserver = new MutationObserver(this.#update);
|
|
144
|
+
this.#contentMutationObserver.observe(next, {
|
|
145
|
+
childList: true,
|
|
146
|
+
subtree: true,
|
|
147
|
+
characterData: true
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
#stopObservingContent() {
|
|
152
|
+
this.#clearDeferredHide();
|
|
153
|
+
if (this.#observedContent) {
|
|
154
|
+
this.#layout.unobserve(this.#observedContent);
|
|
155
|
+
this.#observedContent.removeEventListener("load", this.#update, true);
|
|
156
|
+
}
|
|
157
|
+
this.#observedContent = null;
|
|
158
|
+
this.#contentMutationObserver?.disconnect();
|
|
159
|
+
this.#contentMutationObserver = null;
|
|
160
|
+
this.#layout.unobserveViewport();
|
|
161
|
+
}
|
|
162
|
+
#deferHide(trigger) {
|
|
163
|
+
if (this.#deferredHideTrigger === trigger) return;
|
|
164
|
+
this.#clearDeferredHide();
|
|
165
|
+
this.#deferredHideTrigger = trigger;
|
|
166
|
+
trigger.addEventListener("blur", this.#onDeferredHideBlur);
|
|
167
|
+
}
|
|
168
|
+
#clearDeferredHide() {
|
|
169
|
+
this.#deferredHideTrigger?.removeEventListener("blur", this.#onDeferredHideBlur);
|
|
170
|
+
this.#deferredHideTrigger = null;
|
|
171
|
+
}
|
|
172
|
+
#evaluateOverflow() {
|
|
173
|
+
if (!this.hasTriggerTarget || !this.hasContentTarget) return;
|
|
174
|
+
const trigger = this.triggerTarget;
|
|
175
|
+
const content = this.contentTarget;
|
|
176
|
+
const useful = !this.#collapsed || content.scrollHeight > content.clientHeight;
|
|
177
|
+
if (useful) {
|
|
178
|
+
this.#clearDeferredHide();
|
|
179
|
+
trigger.hidden = false;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (document.activeElement === trigger) {
|
|
183
|
+
trigger.hidden = false;
|
|
184
|
+
this.#deferHide(trigger);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.#clearDeferredHide();
|
|
188
|
+
trigger.hidden = true;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export { ReadMoreController };
|
|
193
|
+
//# sourceMappingURL=read_more_controller.js.map
|
|
194
|
+
//# sourceMappingURL=read_more_controller.js.map
|
|
@@ -58,6 +58,20 @@ var LayoutObserver = class {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// src/utils/logical_scroll.ts
|
|
62
|
+
function isRtl(element) {
|
|
63
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
64
|
+
}
|
|
65
|
+
function logicalScrollMetrics(element, horizontal) {
|
|
66
|
+
const max = Math.max(
|
|
67
|
+
0,
|
|
68
|
+
horizontal ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight
|
|
69
|
+
);
|
|
70
|
+
const raw = horizontal ? element.scrollLeft : element.scrollTop;
|
|
71
|
+
const position = horizontal && isRtl(element) ? -raw : raw;
|
|
72
|
+
return { position: Math.min(max, Math.max(0, position)), max };
|
|
73
|
+
}
|
|
74
|
+
|
|
61
75
|
// src/controllers/scroll_area_controller.ts
|
|
62
76
|
var FOCUSABLE_SELECTOR = [
|
|
63
77
|
"a[href]",
|
|
@@ -131,8 +145,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
131
145
|
*/
|
|
132
146
|
#measurePosition(vp) {
|
|
133
147
|
const horizontalPrimary = this.orientationValue === "horizontal" || this.orientationValue === "both" && vp.scrollHeight <= vp.clientHeight + EDGE_EPSILON;
|
|
134
|
-
const scrollPos
|
|
135
|
-
const maxScroll = horizontalPrimary ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
|
|
148
|
+
const { position: scrollPos, max: maxScroll } = logicalScrollMetrics(vp, horizontalPrimary);
|
|
136
149
|
if (maxScroll <= EDGE_EPSILON) return { position: "start", progress: 0 };
|
|
137
150
|
const progress = Math.min(1, Math.max(0, scrollPos / maxScroll));
|
|
138
151
|
if (scrollPos <= EDGE_EPSILON) return { position: "start", progress };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/scroll_restore_controller.ts
|
|
4
|
+
var ScrollRestoreController = class extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
key: { type: String, default: "" },
|
|
7
|
+
axis: { type: String, default: "vertical" }
|
|
8
|
+
};
|
|
9
|
+
/** Pending rAF id used to coalesce scroll bursts into one save. */
|
|
10
|
+
#rafId = null;
|
|
11
|
+
/** Resolved storage key; empty disables persistence (no key and no id). */
|
|
12
|
+
#storageKey = "";
|
|
13
|
+
/** Last offset captured while the element was live; persisted as-is on teardown. */
|
|
14
|
+
#lastTop = 0;
|
|
15
|
+
#lastLeft = 0;
|
|
16
|
+
#onScroll = () => {
|
|
17
|
+
this.#capture();
|
|
18
|
+
if (this.#rafId !== null) return;
|
|
19
|
+
this.#rafId = requestAnimationFrame(() => {
|
|
20
|
+
this.#rafId = null;
|
|
21
|
+
this.#persist();
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
connect() {
|
|
25
|
+
this.#storageKey = this.#resolveKey();
|
|
26
|
+
if (!this.#storageKey) return;
|
|
27
|
+
this.#restore();
|
|
28
|
+
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
29
|
+
}
|
|
30
|
+
disconnect() {
|
|
31
|
+
if (!this.#storageKey) return;
|
|
32
|
+
this.element.removeEventListener("scroll", this.#onScroll);
|
|
33
|
+
if (this.#rafId !== null) {
|
|
34
|
+
cancelAnimationFrame(this.#rafId);
|
|
35
|
+
this.#rafId = null;
|
|
36
|
+
}
|
|
37
|
+
this.#persist();
|
|
38
|
+
}
|
|
39
|
+
/** Records the live scroll offset for the configured axis. */
|
|
40
|
+
#capture() {
|
|
41
|
+
if (this.#tracksVertical) this.#lastTop = this.element.scrollTop;
|
|
42
|
+
if (this.#tracksHorizontal) this.#lastLeft = this.element.scrollLeft;
|
|
43
|
+
}
|
|
44
|
+
/** Persists the last captured scroll offset for the configured axis. */
|
|
45
|
+
#persist() {
|
|
46
|
+
const data = {};
|
|
47
|
+
if (this.#tracksVertical) data.top = this.#lastTop;
|
|
48
|
+
if (this.#tracksHorizontal) data.left = this.#lastLeft;
|
|
49
|
+
try {
|
|
50
|
+
sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Applies the persisted scroll offset, if any, without moving focus. */
|
|
55
|
+
#restore() {
|
|
56
|
+
let raw = null;
|
|
57
|
+
try {
|
|
58
|
+
raw = sessionStorage.getItem(this.#storageKey);
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (raw === null) return;
|
|
63
|
+
let data;
|
|
64
|
+
try {
|
|
65
|
+
data = JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (this.#tracksVertical && typeof data.top === "number") {
|
|
70
|
+
this.element.scrollTop = data.top;
|
|
71
|
+
this.#lastTop = data.top;
|
|
72
|
+
}
|
|
73
|
+
if (this.#tracksHorizontal && typeof data.left === "number") {
|
|
74
|
+
this.element.scrollLeft = data.left;
|
|
75
|
+
this.#lastLeft = data.left;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
|
|
79
|
+
#resolveKey() {
|
|
80
|
+
const base = this.keyValue || this.element.id;
|
|
81
|
+
return base ? `stimeo--scroll-restore:${base}` : "";
|
|
82
|
+
}
|
|
83
|
+
get #tracksVertical() {
|
|
84
|
+
return this.axisValue !== "horizontal";
|
|
85
|
+
}
|
|
86
|
+
get #tracksHorizontal() {
|
|
87
|
+
return this.axisValue === "horizontal" || this.axisValue === "both";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export { ScrollRestoreController };
|
|
92
|
+
//# sourceMappingURL=scroll_restore_controller.js.map
|
|
93
|
+
//# sourceMappingURL=scroll_restore_controller.js.map
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/scroll_visibility_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/reduced_motion.ts
|
|
6
|
+
function prefersReducedMotion() {
|
|
7
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
// src/controllers/scroll_visibility_controller.ts
|
|
4
11
|
var ScrollVisibilityController = class extends Controller {
|
|
5
12
|
static targets = ["element"];
|
|
@@ -45,7 +52,7 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
45
52
|
}
|
|
46
53
|
/** Scrolls the source to the top and, optionally, moves focus to a safe target. */
|
|
47
54
|
toTop() {
|
|
48
|
-
const behavior =
|
|
55
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
49
56
|
this.#scrollSource.scrollTo({ top: 0, behavior });
|
|
50
57
|
if (this.focusSelectorValue) {
|
|
51
58
|
const target = document.querySelector(this.focusSelectorValue);
|
|
@@ -93,9 +100,6 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
93
100
|
}
|
|
94
101
|
return this.#scrollSource.scrollTop;
|
|
95
102
|
}
|
|
96
|
-
#prefersReducedMotion() {
|
|
97
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
98
|
-
}
|
|
99
103
|
};
|
|
100
104
|
|
|
101
105
|
export { ScrollVisibilityController };
|