stimeo-ui 0.15.0 → 0.17.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +209 -0
- data/README.md +128 -1
- data/dist/cable/index.js +392 -417
- data/dist/controllers/accordion_controller.js +228 -28
- data/dist/controllers/alert_dialog_controller.js +862 -221
- data/dist/controllers/announcer_controller.js +135 -191
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +295 -110
- data/dist/controllers/avatar_controller.js +172 -106
- data/dist/controllers/breadcrumb_controller.js +0 -129
- data/dist/controllers/bulk_select_controller.js +264 -53
- data/dist/controllers/calendar_controller.js +346 -180
- data/dist/controllers/carousel_controller.js +587 -300
- data/dist/controllers/character_counter_controller.js +271 -143
- data/dist/controllers/checkbox_controller.js +77 -75
- data/dist/controllers/clipboard_controller.js +187 -108
- data/dist/controllers/collapsible_controller.js +374 -108
- data/dist/controllers/color_picker_controller.js +183 -127
- data/dist/controllers/combobox_controller.js +426 -109
- data/dist/controllers/command_palette_controller.js +994 -350
- data/dist/controllers/conditional_fields_controller.js +212 -125
- data/dist/controllers/confirm_controller.js +939 -245
- data/dist/controllers/context_menu_controller.js +275 -127
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +235 -97
- data/dist/controllers/currency_input_controller.js +241 -118
- data/dist/controllers/data_grid_controller.js +229 -135
- data/dist/controllers/date_range_picker_controller.js +359 -165
- data/dist/controllers/dialog_controller.js +887 -225
- data/dist/controllers/direct_upload_controller.js +80 -142
- data/dist/controllers/dirty_form_controller.js +26 -47
- data/dist/controllers/dismissible_controller.js +175 -20
- data/dist/controllers/drawer_controller.js +963 -336
- data/dist/controllers/dropdown_controller.js +342 -85
- data/dist/controllers/editable_controller.js +182 -58
- data/dist/controllers/empty_state_controller.js +67 -53
- data/dist/controllers/file_dropzone_controller.js +426 -229
- data/dist/controllers/filter_controller.js +78 -36
- data/dist/controllers/flash_controller.js +328 -211
- data/dist/controllers/focus_controller.js +654 -239
- data/dist/controllers/form_field_controller.js +153 -136
- data/dist/controllers/form_validation_controller.js +29 -96
- data/dist/controllers/frame_loading_controller.js +263 -198
- data/dist/controllers/highlight_controller.js +119 -70
- data/dist/controllers/hover_card_controller.js +335 -118
- data/dist/controllers/idle_controller.js +316 -65
- data/dist/controllers/input_mask_controller.js +116 -61
- data/dist/controllers/intersection_controller.js +147 -103
- data/dist/controllers/lazy_frame_controller.js +9 -56
- data/dist/controllers/listbox_controller.js +413 -153
- data/dist/controllers/local_time_controller.js +54 -64
- data/dist/controllers/masonry_controller.js +70 -95
- data/dist/controllers/menu_controller.js +254 -167
- data/dist/controllers/menubar_controller.js +206 -331
- data/dist/controllers/meter_controller.js +159 -71
- data/dist/controllers/multi_select_controller.js +797 -388
- data/dist/controllers/navigation_menu_controller.js +156 -231
- data/dist/controllers/nested_form_controller.js +373 -174
- data/dist/controllers/network_status_controller.js +241 -45
- data/dist/controllers/number_input_controller.js +387 -227
- data/dist/controllers/optimistic_controller.js +28 -75
- data/dist/controllers/otp_controller.js +344 -196
- data/dist/controllers/overflow_indicator_controller.js +167 -93
- data/dist/controllers/overflow_menu_controller.js +408 -277
- data/dist/controllers/pagination_controller.js +342 -120
- data/dist/controllers/password_reveal_controller.js +424 -99
- data/dist/controllers/password_strength_controller.js +99 -153
- data/dist/controllers/persist_controller.js +252 -96
- data/dist/controllers/pointer_drag_controller.js +263 -164
- data/dist/controllers/popover_controller.js +267 -94
- data/dist/controllers/portal_controller.js +209 -72
- data/dist/controllers/preview_guard_controller.js +243 -112
- data/dist/controllers/progress_controller.js +132 -59
- data/dist/controllers/radio_group_controller.js +138 -133
- data/dist/controllers/range_slider_controller.js +245 -112
- data/dist/controllers/rating_controller.js +429 -194
- data/dist/controllers/read_more_controller.js +185 -47
- data/dist/controllers/reading_progress_controller.js +147 -135
- data/dist/controllers/relative_time_controller.js +145 -110
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- data/dist/controllers/resizable_controller.js +294 -118
- data/dist/controllers/roving_controller.js +39 -84
- data/dist/controllers/scroll_area_controller.js +428 -221
- data/dist/controllers/scroll_restore_controller.js +0 -53
- data/dist/controllers/scroll_visibility_controller.js +395 -146
- data/dist/controllers/scrollspy_controller.js +181 -236
- data/dist/controllers/separator_controller.js +288 -132
- data/dist/controllers/sidebar_controller.js +1071 -375
- data/dist/controllers/skeleton_controller.js +248 -92
- data/dist/controllers/slider_controller.js +211 -96
- data/dist/controllers/smart_sticky_header_controller.js +163 -54
- data/dist/controllers/sortable_controller.js +13 -110
- data/dist/controllers/spinner_controller.js +277 -161
- data/dist/controllers/step_indicator_controller.js +107 -53
- data/dist/controllers/stepper_controller.js +171 -48
- data/dist/controllers/stick_to_bottom_controller.js +310 -116
- data/dist/controllers/sticky_observer_controller.js +214 -49
- data/dist/controllers/submit_once_controller.js +294 -192
- data/dist/controllers/switch_controller.js +107 -43
- data/dist/controllers/tabs_controller.js +50 -25
- data/dist/controllers/tags_input_controller.js +290 -149
- data/dist/controllers/textarea_autosize_controller.js +71 -58
- data/dist/controllers/theme_controller.js +148 -80
- data/dist/controllers/time_picker_controller.js +162 -81
- data/dist/controllers/toast_controller.js +365 -193
- data/dist/controllers/toggle_group_controller.js +194 -99
- data/dist/controllers/toolbar_controller.js +92 -90
- data/dist/controllers/tooltip_controller.js +185 -125
- data/dist/controllers/transition_controller.js +123 -115
- data/dist/controllers/tree_view_controller.js +248 -290
- data/dist/index.js +8649 -8572
- data/dist/positioning/index.js +148 -45
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
// src/controllers/stick_to_bottom_controller.ts
|
|
4
|
-
|
|
5
|
-
// src/utils/layout_observer.ts
|
|
6
3
|
var LayoutObserver = class {
|
|
7
4
|
#callback;
|
|
8
5
|
#resizeObserverFactory;
|
|
9
6
|
#resizeObserver = null;
|
|
10
7
|
#observingViewport = false;
|
|
11
8
|
#loadContainer = null;
|
|
12
|
-
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
13
9
|
#handleViewportResize = () => {
|
|
14
10
|
this.#callback();
|
|
15
11
|
};
|
|
16
|
-
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
17
12
|
#handleDescendantLoad = () => {
|
|
18
13
|
this.#callback();
|
|
19
14
|
};
|
|
@@ -21,11 +16,6 @@ var LayoutObserver = class {
|
|
|
21
16
|
this.#callback = callback;
|
|
22
17
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
23
18
|
}
|
|
24
|
-
/**
|
|
25
|
-
* Starts observing an element's size. Repeated calls observe additional
|
|
26
|
-
* elements through the same shared observer. No-ops when no
|
|
27
|
-
* `ResizeObserver` implementation is available.
|
|
28
|
-
*/
|
|
29
19
|
observe(element) {
|
|
30
20
|
if (!this.#resizeObserverFactory) return;
|
|
31
21
|
if (!this.#resizeObserver) {
|
|
@@ -35,47 +25,28 @@ var LayoutObserver = class {
|
|
|
35
25
|
}
|
|
36
26
|
this.#resizeObserver.observe(element);
|
|
37
27
|
}
|
|
38
|
-
/** Stops observing a single element while leaving any others in place. */
|
|
39
28
|
unobserve(element) {
|
|
40
29
|
this.#resizeObserver?.unobserve(element);
|
|
41
30
|
}
|
|
42
|
-
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
43
31
|
observeViewport() {
|
|
44
32
|
if (this.#observingViewport) return;
|
|
45
33
|
this.#observingViewport = true;
|
|
46
34
|
window.addEventListener("resize", this.#handleViewportResize);
|
|
47
35
|
}
|
|
48
|
-
/** Stops observing viewport resizes without affecting element observation. */
|
|
49
36
|
unobserveViewport() {
|
|
50
37
|
if (!this.#observingViewport) return;
|
|
51
38
|
this.#observingViewport = false;
|
|
52
39
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
53
40
|
}
|
|
54
|
-
/**
|
|
55
|
-
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
56
|
-
* frame settling changes the box it sits in, and it measures as zero high until
|
|
57
|
-
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
58
|
-
* on the container itself and nothing the caller spells.
|
|
59
|
-
*
|
|
60
|
-
* **One container at a time.** A further call moves the observation, so a widget
|
|
61
|
-
* whose content element is swapped at runtime releases the element it let go by
|
|
62
|
-
* naming the new one — there is no second place for the release to drift from.
|
|
63
|
-
*/
|
|
64
41
|
observeDescendantLoads(container) {
|
|
65
42
|
this.unobserveDescendantLoads();
|
|
66
43
|
this.#loadContainer = container;
|
|
67
44
|
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
68
45
|
}
|
|
69
|
-
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
70
46
|
unobserveDescendantLoads() {
|
|
71
47
|
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
72
48
|
this.#loadContainer = null;
|
|
73
49
|
}
|
|
74
|
-
/**
|
|
75
|
-
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
76
|
-
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
77
|
-
* this from a controller's `disconnect()`.
|
|
78
|
-
*/
|
|
79
50
|
disconnect() {
|
|
80
51
|
this.#resizeObserver?.disconnect();
|
|
81
52
|
this.#resizeObserver = null;
|
|
@@ -84,40 +55,313 @@ var LayoutObserver = class {
|
|
|
84
55
|
}
|
|
85
56
|
};
|
|
86
57
|
|
|
87
|
-
|
|
58
|
+
var ListenerSet = class {
|
|
59
|
+
#abort = new AbortController();
|
|
60
|
+
add(target, type, handler, options) {
|
|
61
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
62
|
+
}
|
|
63
|
+
dispose() {
|
|
64
|
+
this.#abort.abort();
|
|
65
|
+
this.#abort = new AbortController();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
var MicrotaskCoalescer = class {
|
|
70
|
+
#run;
|
|
71
|
+
#queued = false;
|
|
72
|
+
#active = false;
|
|
73
|
+
#generation = 0;
|
|
74
|
+
constructor(run) {
|
|
75
|
+
this.#run = run;
|
|
76
|
+
}
|
|
77
|
+
activate() {
|
|
78
|
+
this.#active = true;
|
|
79
|
+
}
|
|
80
|
+
cancel() {
|
|
81
|
+
this.#active = false;
|
|
82
|
+
this.#queued = false;
|
|
83
|
+
this.#generation += 1;
|
|
84
|
+
}
|
|
85
|
+
schedule() {
|
|
86
|
+
if (!this.#active || this.#queued) return;
|
|
87
|
+
this.#queued = true;
|
|
88
|
+
const generation = this.#generation;
|
|
89
|
+
queueMicrotask(() => {
|
|
90
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
91
|
+
this.#queued = false;
|
|
92
|
+
this.#run();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
var MorphRenderWatcher = class {
|
|
98
|
+
#listeners = new ListenerSet();
|
|
99
|
+
#pass;
|
|
100
|
+
#element = null;
|
|
101
|
+
#onMorph = () => this.#pass.schedule();
|
|
102
|
+
constructor(run) {
|
|
103
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
104
|
+
}
|
|
105
|
+
observe(element) {
|
|
106
|
+
if (this.#element === element) return;
|
|
107
|
+
this.disconnect();
|
|
108
|
+
this.#element = element;
|
|
109
|
+
this.#pass.activate();
|
|
110
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
111
|
+
}
|
|
112
|
+
schedule() {
|
|
113
|
+
this.#pass.schedule();
|
|
114
|
+
}
|
|
115
|
+
disconnect() {
|
|
116
|
+
this.#listeners.dispose();
|
|
117
|
+
this.#pass.cancel();
|
|
118
|
+
this.#element = null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
var NUMBER_BOUNDS = {
|
|
123
|
+
nonNegative: { finite: true, min: 0 }};
|
|
124
|
+
function matchesNumberBounds(value, bounds) {
|
|
125
|
+
if (!Number.isFinite(value)) {
|
|
126
|
+
const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
|
|
127
|
+
if (direction === null) return false;
|
|
128
|
+
if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
|
|
129
|
+
}
|
|
130
|
+
if (bounds.min !== void 0 && value < bounds.min) return false;
|
|
131
|
+
if (bounds.max !== void 0 && value > bounds.max) return false;
|
|
132
|
+
if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
|
|
133
|
+
if (bounds.integer && !Number.isInteger(value)) return false;
|
|
134
|
+
if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readNumber(raw, fallback, bounds) {
|
|
139
|
+
return matchesNumberBounds(raw, bounds) ? raw : fallback;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var NumberValueReader = class {
|
|
143
|
+
#lastRejected = /* @__PURE__ */ new Map();
|
|
144
|
+
read(owner, name, raw, fallback, bounds) {
|
|
145
|
+
const resolved = readNumber(raw, fallback, bounds);
|
|
146
|
+
if (matchesNumberBounds(raw, bounds)) {
|
|
147
|
+
this.#lastRejected.delete(name);
|
|
148
|
+
return resolved;
|
|
149
|
+
}
|
|
150
|
+
const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
|
|
151
|
+
const literal = owner.element.getAttribute(attribute);
|
|
152
|
+
if (literal === null) {
|
|
153
|
+
this.#lastRejected.delete(name);
|
|
154
|
+
return resolved;
|
|
155
|
+
}
|
|
156
|
+
if (this.#lastRejected.get(name) !== literal) {
|
|
157
|
+
this.#lastRejected.set(name, literal);
|
|
158
|
+
console.warn(
|
|
159
|
+
`Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return resolved;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
88
166
|
function prefersReducedMotion() {
|
|
89
167
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
90
168
|
}
|
|
91
169
|
|
|
92
|
-
|
|
170
|
+
function sharedRegistry(key, create) {
|
|
171
|
+
const symbol = Symbol.for(key);
|
|
172
|
+
const scope = globalThis;
|
|
173
|
+
const existing = scope[symbol];
|
|
174
|
+
if (existing !== void 0) return existing;
|
|
175
|
+
const registry = create();
|
|
176
|
+
Object.defineProperty(globalThis, symbol, { value: registry });
|
|
177
|
+
return registry;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
var StateRegions = class _StateRegions {
|
|
181
|
+
static #registry = sharedRegistry(
|
|
182
|
+
"stimeo-ui.state-regions.registry.v1",
|
|
183
|
+
() => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
|
|
184
|
+
);
|
|
185
|
+
#whenTrue;
|
|
186
|
+
#whenFalse;
|
|
187
|
+
#record;
|
|
188
|
+
constructor(options, owner) {
|
|
189
|
+
this.#whenTrue = options.whenTrue;
|
|
190
|
+
this.#whenFalse = options.whenFalse ?? null;
|
|
191
|
+
this.#record = `data-${owner}-hidden-region`;
|
|
192
|
+
_StateRegions.#registry.names.add(this.#record);
|
|
193
|
+
}
|
|
194
|
+
reflect(host, isTrue) {
|
|
195
|
+
const shown = this.#inside(host, this.#whenTrue());
|
|
196
|
+
if (!this.#whenFalse) {
|
|
197
|
+
for (const region of shown) this.#write(region, !isTrue);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const hiddenSide = this.#inside(host, this.#whenFalse());
|
|
201
|
+
if (shown.length === 0 || hiddenSide.length === 0) {
|
|
202
|
+
for (const region of shown) this.#give(region);
|
|
203
|
+
for (const region of hiddenSide) this.#give(region);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
for (const region of shown) this.#write(region, !isTrue);
|
|
207
|
+
for (const region of hiddenSide) this.#write(region, isTrue);
|
|
208
|
+
}
|
|
209
|
+
release(host) {
|
|
210
|
+
for (const region of [host, ...host.querySelectorAll("*")]) {
|
|
211
|
+
if (!(region instanceof HTMLElement)) continue;
|
|
212
|
+
const stack = this.#held(region);
|
|
213
|
+
const hold = stack && this.#take(stack);
|
|
214
|
+
if (!stack || !hold) continue;
|
|
215
|
+
const owns = region.hidden === hold.written && !this.#covered(region, stack, hold);
|
|
216
|
+
const index = stack.indexOf(hold);
|
|
217
|
+
stack.splice(index, 1);
|
|
218
|
+
const above = stack[index];
|
|
219
|
+
if (above) above.linked &&= hold.linked;
|
|
220
|
+
if (owns) {
|
|
221
|
+
const below = hold.linked ? stack[index - 1] : void 0;
|
|
222
|
+
if (below) {
|
|
223
|
+
if (region.hidden !== below.written) region.hidden = below.written;
|
|
224
|
+
} else if (region.getAttribute("hidden") !== hold.authored) {
|
|
225
|
+
if (hold.authored === null) region.removeAttribute("hidden");
|
|
226
|
+
else region.setAttribute("hidden", hold.authored);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.#mark(region, stack);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
#write(region, hidden) {
|
|
233
|
+
const stack = this.#stack(region);
|
|
234
|
+
const hold = this.#take(stack) ?? this.#acquire(region, stack);
|
|
235
|
+
const shows = !this.#covered(region, stack, hold);
|
|
236
|
+
hold.written = hidden;
|
|
237
|
+
if (shows && region.hidden !== hidden) region.hidden = hidden;
|
|
238
|
+
this.#mark(region, stack);
|
|
239
|
+
}
|
|
240
|
+
#give(region) {
|
|
241
|
+
const stack = this.#held(region);
|
|
242
|
+
if (stack && this.#take(stack)?.written) this.#write(region, false);
|
|
243
|
+
}
|
|
244
|
+
#held(region) {
|
|
245
|
+
return _StateRegions.#registry.stacks.has(region) || region.hasAttribute(this.#record) ? this.#stack(region) : void 0;
|
|
246
|
+
}
|
|
247
|
+
#stack(region) {
|
|
248
|
+
const stacks = _StateRegions.#registry.stacks;
|
|
249
|
+
const stack = stacks.get(region) ?? this.#seed(region);
|
|
250
|
+
stacks.set(region, stack);
|
|
251
|
+
return stack;
|
|
252
|
+
}
|
|
253
|
+
#seed(region) {
|
|
254
|
+
const found = [];
|
|
255
|
+
for (const record of _StateRegions.#registry.names) {
|
|
256
|
+
const read = this.#recorded(region, record);
|
|
257
|
+
if (read) found.push({ ...read, record });
|
|
258
|
+
}
|
|
259
|
+
const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
|
|
260
|
+
return found.sort((one, other) => depth(one) - depth(other)).map(({ record, authored, written, linked }, index) => ({
|
|
261
|
+
instance: null,
|
|
262
|
+
record,
|
|
263
|
+
authored,
|
|
264
|
+
written,
|
|
265
|
+
linked: linked && index > 0
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
#take(stack) {
|
|
269
|
+
const hold = stack.find((held) => held.instance === this) ?? stack.find((held) => held.instance === null && held.record === this.#record);
|
|
270
|
+
if (hold) hold.instance = this;
|
|
271
|
+
return hold;
|
|
272
|
+
}
|
|
273
|
+
#covered(region, stack, hold) {
|
|
274
|
+
return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === region.hidden);
|
|
275
|
+
}
|
|
276
|
+
#acquire(region, stack) {
|
|
277
|
+
const top = stack.at(-1);
|
|
278
|
+
const over = top && region.hidden === top.written ? top : void 0;
|
|
279
|
+
const hold = {
|
|
280
|
+
instance: this,
|
|
281
|
+
record: this.#record,
|
|
282
|
+
authored: over ? over.authored : region.getAttribute("hidden"),
|
|
283
|
+
written: false,
|
|
284
|
+
linked: over !== void 0
|
|
285
|
+
};
|
|
286
|
+
stack.push(hold);
|
|
287
|
+
return hold;
|
|
288
|
+
}
|
|
289
|
+
#mark(region, stack) {
|
|
290
|
+
const records = /* @__PURE__ */ new Map([[this.#record, null]]);
|
|
291
|
+
stack.forEach((hold, index) => {
|
|
292
|
+
const names = stack.slice(0, index).map((under) => under.record);
|
|
293
|
+
const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
|
|
294
|
+
const marker = hold.linked || beneath.length === 0 ? [] : [false];
|
|
295
|
+
records.set(
|
|
296
|
+
hold.record,
|
|
297
|
+
region.getAttribute("hidden") === hold.authored && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.authored, hold.written, ...marker, ...beneath])
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
for (const [name, recorded] of records) {
|
|
301
|
+
if (region.getAttribute(name) === recorded) continue;
|
|
302
|
+
if (recorded === null) region.removeAttribute(name);
|
|
303
|
+
else region.setAttribute(name, recorded);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
#recorded(region, name) {
|
|
307
|
+
try {
|
|
308
|
+
const parsed = JSON.parse(region.getAttribute(name) ?? "null");
|
|
309
|
+
if (!Array.isArray(parsed)) return null;
|
|
310
|
+
const [authored, written, ...rest] = parsed;
|
|
311
|
+
if (authored !== null && typeof authored !== "string") return null;
|
|
312
|
+
const linked = rest[0] !== false;
|
|
313
|
+
const beneath = linked ? rest : rest.slice(1);
|
|
314
|
+
if (!beneath.every((under) => typeof under === "string")) return null;
|
|
315
|
+
return typeof written === "boolean" ? { authored, written, linked, beneath } : null;
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
#inside(host, regions) {
|
|
321
|
+
return regions.filter((region) => host.contains(region));
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
93
325
|
var DEFAULT_THRESHOLD = 80;
|
|
94
326
|
var countElements = (nodes) => {
|
|
95
327
|
let n = 0;
|
|
96
328
|
for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
|
|
97
329
|
return n;
|
|
98
330
|
};
|
|
99
|
-
var StickToBottomController = class extends Controller {
|
|
100
|
-
|
|
331
|
+
var StickToBottomController = class _StickToBottomController extends Controller {
|
|
332
|
+
#numbers = new NumberValueReader();
|
|
333
|
+
static targets = ["content", "hasNew"];
|
|
101
334
|
static values = {
|
|
102
335
|
threshold: { type: Number, default: DEFAULT_THRESHOLD },
|
|
103
336
|
behavior: { type: String, default: "auto" },
|
|
104
337
|
pinOnConnect: { type: Boolean, default: false }
|
|
105
338
|
};
|
|
339
|
+
static valueConstraints = {
|
|
340
|
+
threshold: NUMBER_BOUNDS.nonNegative
|
|
341
|
+
};
|
|
106
342
|
static actions = ["scrollToBottom"];
|
|
107
343
|
static events = ["pin", "new"];
|
|
108
344
|
#observer = null;
|
|
109
|
-
/** The element the append observer currently holds, so a swap can be detected. */
|
|
110
345
|
#watched = null;
|
|
111
|
-
/** Watches for the box a container connected without one is still waiting on. */
|
|
112
346
|
#layout = new LayoutObserver(() => this.#onLaidOut());
|
|
113
347
|
#awaitingLayout = false;
|
|
114
348
|
#connected = false;
|
|
115
349
|
#pinned = false;
|
|
350
|
+
#hasNew = false;
|
|
351
|
+
#hasNewRegion = new StateRegions(
|
|
352
|
+
{ whenTrue: () => this.hasNewTargets },
|
|
353
|
+
this.identifier
|
|
354
|
+
);
|
|
116
355
|
#onScroll = () => this.#updatePinned();
|
|
356
|
+
#morphRender = new MorphRenderWatcher(() => {
|
|
357
|
+
this.#updatePinned();
|
|
358
|
+
this.#setHasNew(this.#hasNew);
|
|
359
|
+
});
|
|
117
360
|
connect() {
|
|
361
|
+
this.#morphRender.observe(this.element);
|
|
118
362
|
this.#connected = true;
|
|
119
363
|
if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
|
|
120
|
-
this
|
|
364
|
+
this.#setHasNew(false);
|
|
121
365
|
this.#pinned = this.#isPinned();
|
|
122
366
|
this.#reflectPinned();
|
|
123
367
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
@@ -125,44 +369,31 @@ var StickToBottomController = class extends Controller {
|
|
|
125
369
|
if (!this.#measurable()) this.#waitForLayout();
|
|
126
370
|
}
|
|
127
371
|
disconnect() {
|
|
372
|
+
this.#morphRender.disconnect();
|
|
128
373
|
this.#connected = false;
|
|
129
374
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
130
375
|
this.#stopWatching();
|
|
131
376
|
this.#stopWaitingForLayout();
|
|
132
377
|
}
|
|
133
|
-
/** Moves the append watch onto a `content` target that arrived at runtime. */
|
|
134
378
|
contentTargetConnected() {
|
|
135
379
|
this.#syncWatched();
|
|
136
380
|
}
|
|
137
|
-
/** Moves the append watch off a `content` target that left, back onto the container. */
|
|
138
381
|
contentTargetDisconnected() {
|
|
139
382
|
this.#syncWatched();
|
|
140
383
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
384
|
+
hasNewTargetConnected() {
|
|
385
|
+
if (!this.#connected) return;
|
|
386
|
+
this.#hasNewRegion.reflect(this.element, this.#hasNew);
|
|
387
|
+
}
|
|
145
388
|
thresholdValueChanged() {
|
|
146
389
|
if (!this.#connected) return;
|
|
147
390
|
this.#updatePinned();
|
|
148
391
|
}
|
|
149
|
-
/**
|
|
150
|
-
* Jumps to the bottom and re-pins (wired to a "new messages" button).
|
|
151
|
-
*
|
|
152
|
-
* The has-new flag clears on request — the user has acknowledged the arrival — while
|
|
153
|
-
* pinned is read back from where the scroll landed: a jump that arrives by the time
|
|
154
|
-
* this returns pins immediately, an animated one settles from its own scroll events,
|
|
155
|
-
* and a jump the engine cannot honor leaves the container unpinned, so the next append
|
|
156
|
-
* flags it again instead of being swallowed by a pinned state that does not hold.
|
|
157
|
-
*
|
|
158
|
-
* Which of those happens is not this method's to decide — see {@link behaviorValue}.
|
|
159
|
-
*/
|
|
160
392
|
scrollToBottom() {
|
|
161
393
|
this.#scrollToBottom();
|
|
162
|
-
this
|
|
394
|
+
this.#setHasNew(false);
|
|
163
395
|
this.#updatePinned();
|
|
164
396
|
}
|
|
165
|
-
/** Follows appended children while pinned; otherwise flags new content. */
|
|
166
397
|
#onMutations(mutations) {
|
|
167
398
|
let added = 0;
|
|
168
399
|
for (const mutation of mutations) added += countElements(mutation.addedNodes);
|
|
@@ -170,86 +401,59 @@ var StickToBottomController = class extends Controller {
|
|
|
170
401
|
if (this.#pinned) {
|
|
171
402
|
this.#scrollToBottom();
|
|
172
403
|
} else {
|
|
173
|
-
this
|
|
404
|
+
this.#setHasNew(true);
|
|
174
405
|
this.dispatch("new", { detail: { count: added } });
|
|
175
406
|
}
|
|
176
407
|
}
|
|
177
|
-
/**
|
|
178
|
-
* Recomputes pinned from the scroll position and reflects it on a transition.
|
|
179
|
-
*
|
|
180
|
-
* @stimeoRenderRoot
|
|
181
|
-
*/
|
|
182
408
|
#updatePinned() {
|
|
183
409
|
const pinned = this.#isPinned();
|
|
184
|
-
|
|
410
|
+
const changed = pinned !== this.#pinned;
|
|
185
411
|
this.#pinned = pinned;
|
|
186
412
|
this.#reflectPinned();
|
|
187
|
-
this.dispatch("pin", { detail: { pinned } });
|
|
413
|
+
if (changed) this.dispatch("pin", { detail: { pinned } });
|
|
188
414
|
}
|
|
189
|
-
/** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */
|
|
190
415
|
#reflectPinned() {
|
|
191
416
|
if (this.#pinned) {
|
|
192
417
|
this.element.setAttribute("data-pinned", "true");
|
|
193
|
-
this
|
|
418
|
+
this.#setHasNew(false);
|
|
194
419
|
} else {
|
|
195
420
|
this.element.removeAttribute("data-pinned");
|
|
196
421
|
}
|
|
197
422
|
}
|
|
198
|
-
|
|
423
|
+
#setHasNew(hasNew) {
|
|
424
|
+
this.#hasNew = hasNew;
|
|
425
|
+
if (hasNew) {
|
|
426
|
+
this.element.setAttribute("data-has-new", "true");
|
|
427
|
+
} else {
|
|
428
|
+
this.element.removeAttribute("data-has-new");
|
|
429
|
+
}
|
|
430
|
+
this.#hasNewRegion.reflect(this.element, hasNew);
|
|
431
|
+
}
|
|
199
432
|
#isPinned() {
|
|
200
433
|
if (!this.#measurable()) return false;
|
|
201
434
|
const el = this.element;
|
|
202
435
|
return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
|
|
203
436
|
}
|
|
204
|
-
/**
|
|
205
|
-
* The distance from the bottom that counts as pinned: a finite, non-negative number of
|
|
206
|
-
* pixels. Anything else names no distance the container can be at, and settles the
|
|
207
|
-
* comparison the same way at every scroll position, so it falls back to the default.
|
|
208
|
-
* `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
|
|
209
|
-
* distance sits below the closest the container ever gets; `Infinity` is never
|
|
210
|
-
* exceeded. The first two stop following and flag every append as new, and the last
|
|
211
|
-
* never stops following — it takes the reading position the flag exists to protect.
|
|
212
|
-
* Zero is a real declaration: it pins at the exact bottom only.
|
|
213
|
-
*/
|
|
214
437
|
get #threshold() {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* Whether the container has a box to scroll and to measure. One that is not rendered
|
|
220
|
-
* (inside a closed panel) reports every metric as 0, which reads as "already at the
|
|
221
|
-
* bottom" — a position describing no layout the user will ever see.
|
|
222
|
-
*/
|
|
438
|
+
return this.#safeThreshold;
|
|
439
|
+
}
|
|
223
440
|
#measurable() {
|
|
224
441
|
return this.element.clientHeight > 0;
|
|
225
442
|
}
|
|
226
|
-
/**
|
|
227
|
-
* Holds the pinned decision until the container is laid out — otherwise the panel opens
|
|
228
|
-
* at the top while the state claims the bottom, and the appends that arrived meanwhile
|
|
229
|
-
* were followed into a box that could not move rather than flagged.
|
|
230
|
-
*/
|
|
231
443
|
#waitForLayout() {
|
|
232
444
|
this.#awaitingLayout = true;
|
|
233
445
|
this.#layout.observe(this.element);
|
|
234
446
|
}
|
|
235
|
-
/** Runs the held decision once the container has the box it was waiting for. */
|
|
236
447
|
#onLaidOut() {
|
|
237
448
|
if (!this.#awaitingLayout || !this.#measurable()) return;
|
|
238
449
|
this.#stopWaitingForLayout();
|
|
239
450
|
if (this.pinOnConnectValue) this.#scrollToBottom("instant");
|
|
240
451
|
this.#updatePinned();
|
|
241
452
|
}
|
|
242
|
-
/** Releases the layout watch, whether or not the held decision ever ran. */
|
|
243
453
|
#stopWaitingForLayout() {
|
|
244
454
|
this.#awaitingLayout = false;
|
|
245
455
|
this.#layout.disconnect();
|
|
246
456
|
}
|
|
247
|
-
/**
|
|
248
|
-
* Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
|
|
249
|
-
* 0 for a container tall enough to hold its whole content, so the jump moves nothing
|
|
250
|
-
* there. `behavior` defaults to the configured follow behavior; pass `"instant"` for a
|
|
251
|
-
* jump that must not animate.
|
|
252
|
-
*/
|
|
253
457
|
#scrollToBottom(behavior = this.#behavior()) {
|
|
254
458
|
const top = this.element.scrollHeight;
|
|
255
459
|
if (typeof this.element.scrollTo === "function") {
|
|
@@ -258,17 +462,6 @@ var StickToBottomController = class extends Controller {
|
|
|
258
462
|
this.element.scrollTop = top;
|
|
259
463
|
}
|
|
260
464
|
}
|
|
261
|
-
/**
|
|
262
|
-
* Points the append watch at the current `content` target, or at the container when
|
|
263
|
-
* there is none. Re-resolved whenever that target changes, so a swap does not leave the
|
|
264
|
-
* observer holding a detached node whose appends nobody sees.
|
|
265
|
-
*
|
|
266
|
-
* Stimulus runs the target callbacks outside the connected window too — before
|
|
267
|
-
* `connect()` for a target already in the DOM, and after `disconnect()` while the
|
|
268
|
-
* element is torn down — where this would arm an observer nothing releases. Re-syncing
|
|
269
|
-
* to the target already held is left alone, so an arrival still in flight is not
|
|
270
|
-
* dropped with the observer that was about to deliver it.
|
|
271
|
-
*/
|
|
272
465
|
#syncWatched() {
|
|
273
466
|
if (!this.#connected) return;
|
|
274
467
|
const next = this.hasContentTarget ? this.contentTarget : this.element;
|
|
@@ -279,24 +472,25 @@ var StickToBottomController = class extends Controller {
|
|
|
279
472
|
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
280
473
|
this.#observer.observe(next, { childList: true });
|
|
281
474
|
}
|
|
282
|
-
/** Releases the append watch and the element it held. */
|
|
283
475
|
#stopWatching() {
|
|
284
476
|
this.#observer?.disconnect();
|
|
285
477
|
this.#observer = null;
|
|
286
478
|
this.#watched = null;
|
|
287
479
|
}
|
|
288
|
-
/**
|
|
289
|
-
* The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
|
|
290
|
-
* once: it hands the decision to the element's computed `scroll-behavior`, so a
|
|
291
|
-
* consumer stylesheet saying `smooth` animates these scrolls too. Only `"instant"`
|
|
292
|
-
* overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.
|
|
293
|
-
*/
|
|
294
480
|
#behavior() {
|
|
295
481
|
if (prefersReducedMotion()) return "instant";
|
|
296
482
|
return this.behaviorValue === "smooth" ? "smooth" : "auto";
|
|
297
483
|
}
|
|
484
|
+
get #safeThreshold() {
|
|
485
|
+
return this.#numbers.read(
|
|
486
|
+
this,
|
|
487
|
+
"threshold",
|
|
488
|
+
this.thresholdValue,
|
|
489
|
+
_StickToBottomController.values.threshold.default,
|
|
490
|
+
_StickToBottomController.valueConstraints.threshold
|
|
491
|
+
);
|
|
492
|
+
}
|
|
298
493
|
};
|
|
299
494
|
|
|
300
495
|
export { StickToBottomController };
|
|
301
|
-
//# sourceMappingURL=stick_to_bottom_controller.js.map
|
|
302
496
|
//# sourceMappingURL=stick_to_bottom_controller.js.map
|