stimeo-ui 0.1.0.pre.beta.3 → 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 +149 -0
- data/README.md +6 -6
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/character_counter_controller.js +52 -15
- 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/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +243 -39
- data/dist/controllers/confirm_controller.js +94 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/countdown_controller.js +2 -2
- 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/dialog_controller.js +94 -8
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/dropdown_controller.js +139 -16
- 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/focus_controller.js +94 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +181 -8
- 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/otp_controller.js +62 -22
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- 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 +144 -38
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +1644 -643
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +28 -2
|
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/focus_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
var EscapeLayer = class _EscapeLayer {
|
|
7
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
#ownerDocument = null;
|
|
9
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
10
|
+
#onDismiss = null;
|
|
11
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
12
|
+
#claims = null;
|
|
13
|
+
/**
|
|
14
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
15
|
+
* the document's shared resolver listener if this is its first layer.
|
|
16
|
+
* Re-activating an already-active layer moves it to the top.
|
|
17
|
+
*/
|
|
18
|
+
activate(ownerDocument = document, options) {
|
|
19
|
+
this.deactivate();
|
|
20
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
21
|
+
if (!registry) {
|
|
22
|
+
registry = _EscapeLayer.#createRegistry();
|
|
23
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
24
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
25
|
+
}
|
|
26
|
+
registry.stack.push(this);
|
|
27
|
+
this.#ownerDocument = ownerDocument;
|
|
28
|
+
this.#onDismiss = options.onDismiss;
|
|
29
|
+
this.#claims = options.claims ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
33
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
34
|
+
*/
|
|
35
|
+
deactivate() {
|
|
36
|
+
const ownerDocument = this.#ownerDocument;
|
|
37
|
+
if (!ownerDocument) return;
|
|
38
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
39
|
+
if (registry) {
|
|
40
|
+
const index = registry.stack.lastIndexOf(this);
|
|
41
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
42
|
+
if (registry.stack.length === 0) {
|
|
43
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
44
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.#ownerDocument = null;
|
|
48
|
+
this.#onDismiss = null;
|
|
49
|
+
this.#claims = null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
53
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
54
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
55
|
+
*/
|
|
56
|
+
get ownsEscape() {
|
|
57
|
+
const ownerDocument = this.#ownerDocument;
|
|
58
|
+
if (!ownerDocument) return false;
|
|
59
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
60
|
+
if (!registry) return false;
|
|
61
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
62
|
+
}
|
|
63
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
64
|
+
static #createRegistry() {
|
|
65
|
+
const registry = {
|
|
66
|
+
stack: [],
|
|
67
|
+
onKeydown: (event) => {
|
|
68
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
69
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
70
|
+
if (!owner) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
owner.#onDismiss?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
78
|
+
static #resolveOwner(stack) {
|
|
79
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
80
|
+
const layer = stack[index];
|
|
81
|
+
if (!layer) continue;
|
|
82
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
83
|
+
return layer;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
5
89
|
// src/utils/focus_trap.ts
|
|
6
90
|
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
91
|
var FocusTrap = class {
|
|
@@ -15,6 +99,8 @@ var FocusTrap = class {
|
|
|
15
99
|
#inertedSiblings = [];
|
|
16
100
|
/** Whether the modal side effects are currently applied. */
|
|
17
101
|
#activeState = false;
|
|
102
|
+
/** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
|
|
103
|
+
#escapeLayer = new EscapeLayer();
|
|
18
104
|
/** Returns the trapped element; called on every operation for the live target. */
|
|
19
105
|
#getContainer;
|
|
20
106
|
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
@@ -50,6 +136,8 @@ var FocusTrap = class {
|
|
|
50
136
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
51
137
|
document.addEventListener("keydown", this.#onKeydown);
|
|
52
138
|
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
139
|
+
const onEscape = this.#options.onEscape;
|
|
140
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
53
141
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
54
142
|
}
|
|
55
143
|
/**
|
|
@@ -62,6 +150,7 @@ var FocusTrap = class {
|
|
|
62
150
|
deactivate({ restoreFocus = true } = {}) {
|
|
63
151
|
if (!this.#activeState) return;
|
|
64
152
|
this.#activeState = false;
|
|
153
|
+
this.#escapeLayer.deactivate();
|
|
65
154
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
66
155
|
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
67
156
|
if (this.#scrollLocked) {
|
|
@@ -90,15 +179,12 @@ var FocusTrap = class {
|
|
|
90
179
|
#onBeforeCache = () => {
|
|
91
180
|
this.deactivate({ restoreFocus: false });
|
|
92
181
|
};
|
|
93
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
|
+
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
185
|
+
* which layer currently owns Escape.
|
|
186
|
+
*/
|
|
94
187
|
#onKeydown = (event) => {
|
|
95
|
-
if (event.key === "Escape") {
|
|
96
|
-
if (this.#options.onEscape) {
|
|
97
|
-
event.preventDefault();
|
|
98
|
-
this.#options.onEscape();
|
|
99
|
-
}
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
188
|
if (event.key === "Tab") this.#trapTab(event);
|
|
103
189
|
};
|
|
104
190
|
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
@@ -181,8 +181,8 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
181
181
|
}
|
|
182
182
|
/**
|
|
183
183
|
* Applies (or clears) a declarative custom constraint via `setCustomValidity`,
|
|
184
|
-
* for controls that opt in with `data-stimeo--form-field-disallow`. The
|
|
185
|
-
*
|
|
184
|
+
* for controls that opt in with `data-stimeo--form-field-disallow`. The supported
|
|
185
|
+
* rule is `"whitespace"` — a value that is non-empty but blank
|
|
186
186
|
* after trimming (which slips past `required` / `minlength`); its message follows
|
|
187
187
|
* the per-constraint (`value-missing`) → generic → default chain.
|
|
188
188
|
*
|
|
@@ -2,6 +2,11 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/highlight_controller.ts
|
|
4
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
|
+
|
|
5
10
|
// src/utils/safe_timeout.ts
|
|
6
11
|
var TimerRegistry = class {
|
|
7
12
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -89,7 +94,7 @@ var HighlightController = class extends Controller {
|
|
|
89
94
|
}
|
|
90
95
|
/** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
|
|
91
96
|
#highlight(el) {
|
|
92
|
-
if (
|
|
97
|
+
if (prefersReducedMotion()) return;
|
|
93
98
|
el.setAttribute("data-highlight", "true");
|
|
94
99
|
this.dispatch("start", { target: el, detail: { element: el } });
|
|
95
100
|
this.#timeouts.set(() => {
|
|
@@ -97,9 +102,6 @@ var HighlightController = class extends Controller {
|
|
|
97
102
|
this.dispatch("end", { target: el, detail: { element: el } });
|
|
98
103
|
}, this.durationValue);
|
|
99
104
|
}
|
|
100
|
-
#prefersReducedMotion() {
|
|
101
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
102
|
-
}
|
|
103
105
|
};
|
|
104
106
|
|
|
105
107
|
export { HighlightController };
|
|
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/hover_card_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
var EscapeLayer = class _EscapeLayer {
|
|
7
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
#ownerDocument = null;
|
|
9
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
10
|
+
#onDismiss = null;
|
|
11
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
12
|
+
#claims = null;
|
|
13
|
+
/**
|
|
14
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
15
|
+
* the document's shared resolver listener if this is its first layer.
|
|
16
|
+
* Re-activating an already-active layer moves it to the top.
|
|
17
|
+
*/
|
|
18
|
+
activate(ownerDocument = document, options) {
|
|
19
|
+
this.deactivate();
|
|
20
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
21
|
+
if (!registry) {
|
|
22
|
+
registry = _EscapeLayer.#createRegistry();
|
|
23
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
24
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
25
|
+
}
|
|
26
|
+
registry.stack.push(this);
|
|
27
|
+
this.#ownerDocument = ownerDocument;
|
|
28
|
+
this.#onDismiss = options.onDismiss;
|
|
29
|
+
this.#claims = options.claims ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
33
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
34
|
+
*/
|
|
35
|
+
deactivate() {
|
|
36
|
+
const ownerDocument = this.#ownerDocument;
|
|
37
|
+
if (!ownerDocument) return;
|
|
38
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
39
|
+
if (registry) {
|
|
40
|
+
const index = registry.stack.lastIndexOf(this);
|
|
41
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
42
|
+
if (registry.stack.length === 0) {
|
|
43
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
44
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.#ownerDocument = null;
|
|
48
|
+
this.#onDismiss = null;
|
|
49
|
+
this.#claims = null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
53
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
54
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
55
|
+
*/
|
|
56
|
+
get ownsEscape() {
|
|
57
|
+
const ownerDocument = this.#ownerDocument;
|
|
58
|
+
if (!ownerDocument) return false;
|
|
59
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
60
|
+
if (!registry) return false;
|
|
61
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
62
|
+
}
|
|
63
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
64
|
+
static #createRegistry() {
|
|
65
|
+
const registry = {
|
|
66
|
+
stack: [],
|
|
67
|
+
onKeydown: (event) => {
|
|
68
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
69
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
70
|
+
if (!owner) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
owner.#onDismiss?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
78
|
+
static #resolveOwner(stack) {
|
|
79
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
80
|
+
const layer = stack[index];
|
|
81
|
+
if (!layer) continue;
|
|
82
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
83
|
+
return layer;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
5
89
|
// src/utils/safe_timeout.ts
|
|
6
90
|
var TimerRegistry = class {
|
|
7
91
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -88,21 +172,26 @@ var HoverCardController = class extends Controller {
|
|
|
88
172
|
closeDelay: { type: Number, default: 200 },
|
|
89
173
|
closeOnScroll: { type: Boolean, default: false }
|
|
90
174
|
};
|
|
91
|
-
static actions = ["close", "
|
|
92
|
-
/** Pending open/close timers,
|
|
175
|
+
static actions = ["close", "open"];
|
|
176
|
+
/** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
|
|
93
177
|
#timers = new SafeTimeout();
|
|
178
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
179
|
+
#escapeLayer = new EscapeLayer();
|
|
94
180
|
#pendingOpen = null;
|
|
95
181
|
#pendingClose = null;
|
|
96
182
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
97
183
|
#stopScrollDismiss = null;
|
|
98
|
-
/** Starts closed. */
|
|
184
|
+
/** Starts closed and discards any stale pending state from a prior connection. */
|
|
99
185
|
connect() {
|
|
186
|
+
this.#cancelOpen();
|
|
187
|
+
this.#cancelClose();
|
|
100
188
|
this.#conceal();
|
|
101
189
|
}
|
|
102
|
-
/** Clears timers
|
|
190
|
+
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
103
191
|
disconnect() {
|
|
104
|
-
this.#
|
|
105
|
-
|
|
192
|
+
this.#cancelOpen();
|
|
193
|
+
this.#cancelClose();
|
|
194
|
+
this.#escapeLayer.deactivate();
|
|
106
195
|
this.#stopScrollDismiss?.();
|
|
107
196
|
this.#stopScrollDismiss = null;
|
|
108
197
|
}
|
|
@@ -134,32 +223,26 @@ var HoverCardController = class extends Controller {
|
|
|
134
223
|
this.#conceal();
|
|
135
224
|
}, this.closeDelayValue);
|
|
136
225
|
}
|
|
137
|
-
/**
|
|
138
|
-
onKeydown(event) {
|
|
139
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
140
|
-
event.preventDefault();
|
|
141
|
-
this.#dismiss();
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */
|
|
226
|
+
/** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
|
|
145
227
|
#reveal() {
|
|
146
228
|
if (!this.hasCardTarget) return;
|
|
147
229
|
this.cardTarget.hidden = false;
|
|
148
230
|
this.cardTarget.setAttribute("data-state", "open");
|
|
149
231
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
150
|
-
|
|
232
|
+
this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
|
|
151
233
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
152
234
|
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
153
235
|
}
|
|
154
236
|
}
|
|
155
|
-
/** Hides the card, reflects state, and
|
|
237
|
+
/** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
|
|
156
238
|
#conceal() {
|
|
157
|
-
|
|
239
|
+
this.#escapeLayer.deactivate();
|
|
158
240
|
this.#stopScrollDismiss?.();
|
|
159
241
|
this.#stopScrollDismiss = null;
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
242
|
+
if (this.hasCardTarget) {
|
|
243
|
+
this.cardTarget.hidden = true;
|
|
244
|
+
this.cardTarget.setAttribute("data-state", "closed");
|
|
245
|
+
}
|
|
163
246
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
164
247
|
}
|
|
165
248
|
/** Cancels pending timers and conceals immediately (shared Escape path). */
|
|
@@ -168,13 +251,6 @@ var HoverCardController = class extends Controller {
|
|
|
168
251
|
this.#cancelClose();
|
|
169
252
|
this.#conceal();
|
|
170
253
|
}
|
|
171
|
-
/** Document-level `Escape` watcher (active only while open). */
|
|
172
|
-
#onDocumentKeydown = (event) => {
|
|
173
|
-
if (event.key === "Escape") {
|
|
174
|
-
event.preventDefault();
|
|
175
|
-
this.#dismiss();
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
254
|
/** Cancels any pending open timer. */
|
|
179
255
|
#cancelOpen() {
|
|
180
256
|
if (this.#pendingOpen !== null) {
|
|
@@ -3,6 +3,12 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
3
3
|
// src/controllers/intersection_controller.ts
|
|
4
4
|
|
|
5
5
|
// src/utils/intersection_watcher.ts
|
|
6
|
+
function isBeforeRootStart(entry) {
|
|
7
|
+
const rect = entry.boundingClientRect;
|
|
8
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
9
|
+
const rootTop = entry.rootBounds?.top ?? 0;
|
|
10
|
+
return rect.bottom <= rootTop;
|
|
11
|
+
}
|
|
6
12
|
var IntersectionWatcher = class {
|
|
7
13
|
#onEntries;
|
|
8
14
|
#observer = null;
|
|
@@ -18,6 +24,11 @@ var IntersectionWatcher = class {
|
|
|
18
24
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
25
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
26
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
27
|
+
*
|
|
28
|
+
* @throws Whatever the platform throws for an invalid `rootMargin`/`threshold`
|
|
29
|
+
* or a failing `observe()`. The exception is passed through unchanged, but
|
|
30
|
+
* the watcher rolls back first: every target observed so far is released and
|
|
31
|
+
* `active` stays `false`, so a caller that retries starts from a clean slate.
|
|
21
32
|
*/
|
|
22
33
|
start(targets, options = {}) {
|
|
23
34
|
this.stop();
|
|
@@ -25,25 +36,42 @@ var IntersectionWatcher = class {
|
|
|
25
36
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
37
|
if (list.length === 0) return false;
|
|
27
38
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
let observer = null;
|
|
40
|
+
try {
|
|
41
|
+
observer = new IntersectionObserver(
|
|
42
|
+
(entries) => {
|
|
43
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
44
|
+
},
|
|
45
|
+
{ root, rootMargin: options.rootMargin, threshold: options.threshold }
|
|
46
|
+
);
|
|
47
|
+
for (const target of list) observer.observe(target);
|
|
48
|
+
this.#observer = observer;
|
|
49
|
+
this.#active = true;
|
|
50
|
+
return true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
observer?.disconnect();
|
|
53
|
+
this.#observer = null;
|
|
54
|
+
this.#active = false;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
37
57
|
}
|
|
38
58
|
/**
|
|
39
59
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
60
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
61
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
62
|
+
*
|
|
63
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
64
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
65
|
*/
|
|
43
66
|
rearm(target) {
|
|
44
67
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
try {
|
|
69
|
+
this.#observer.unobserve(target);
|
|
70
|
+
this.#observer.observe(target);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.stop();
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
47
75
|
}
|
|
48
76
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
77
|
stop() {
|
|
@@ -80,7 +108,7 @@ var IntersectionController = class extends Controller {
|
|
|
80
108
|
this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
|
|
81
109
|
this.dispatch("change", { detail: { intersecting, ratio } });
|
|
82
110
|
this.#syncIntersecting(intersecting, ratio, entry);
|
|
83
|
-
this.#syncPassed(!intersecting &&
|
|
111
|
+
this.#syncPassed(!intersecting && isBeforeRootStart(entry));
|
|
84
112
|
}
|
|
85
113
|
}
|
|
86
114
|
connect() {
|
|
@@ -128,7 +156,7 @@ var IntersectionController = class extends Controller {
|
|
|
128
156
|
if (this.onceValue) this.#watcher.stop();
|
|
129
157
|
} else if (!intersecting && previous === "true") {
|
|
130
158
|
this.dispatch("exit", {
|
|
131
|
-
detail: { ratio, position:
|
|
159
|
+
detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
|
|
132
160
|
});
|
|
133
161
|
}
|
|
134
162
|
}
|
|
@@ -144,11 +172,6 @@ var IntersectionController = class extends Controller {
|
|
|
144
172
|
const changed = previous === null ? passed : previous === "true" !== passed;
|
|
145
173
|
if (changed) this.dispatch("passed", { detail: { passed } });
|
|
146
174
|
}
|
|
147
|
-
/** True when the element sits entirely before the root's start (top) edge. */
|
|
148
|
-
#isBefore(entry) {
|
|
149
|
-
const rootTop = entry.rootBounds?.top ?? 0;
|
|
150
|
-
return entry.boundingClientRect.bottom <= rootTop;
|
|
151
|
-
}
|
|
152
175
|
/** The configured `threshold`, clamped to the 0..1 the observer accepts. */
|
|
153
176
|
#clampedThreshold() {
|
|
154
177
|
return Math.min(1, Math.max(0, this.thresholdValue));
|
|
@@ -18,6 +18,11 @@ var IntersectionWatcher = class {
|
|
|
18
18
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
19
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
20
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
21
|
+
*
|
|
22
|
+
* @throws Whatever the platform throws for an invalid `rootMargin`/`threshold`
|
|
23
|
+
* or a failing `observe()`. The exception is passed through unchanged, but
|
|
24
|
+
* the watcher rolls back first: every target observed so far is released and
|
|
25
|
+
* `active` stays `false`, so a caller that retries starts from a clean slate.
|
|
21
26
|
*/
|
|
22
27
|
start(targets, options = {}) {
|
|
23
28
|
this.stop();
|
|
@@ -25,25 +30,42 @@ var IntersectionWatcher = class {
|
|
|
25
30
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
31
|
if (list.length === 0) return false;
|
|
27
32
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
let observer = null;
|
|
34
|
+
try {
|
|
35
|
+
observer = new IntersectionObserver(
|
|
36
|
+
(entries) => {
|
|
37
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
38
|
+
},
|
|
39
|
+
{ root, rootMargin: options.rootMargin, threshold: options.threshold }
|
|
40
|
+
);
|
|
41
|
+
for (const target of list) observer.observe(target);
|
|
42
|
+
this.#observer = observer;
|
|
43
|
+
this.#active = true;
|
|
44
|
+
return true;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
observer?.disconnect();
|
|
47
|
+
this.#observer = null;
|
|
48
|
+
this.#active = false;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
37
51
|
}
|
|
38
52
|
/**
|
|
39
53
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
54
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
55
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
56
|
+
*
|
|
57
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
58
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
59
|
*/
|
|
43
60
|
rearm(target) {
|
|
44
61
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
try {
|
|
63
|
+
this.#observer.unobserve(target);
|
|
64
|
+
this.#observer.observe(target);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.stop();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
47
69
|
}
|
|
48
70
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
71
|
stop() {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/masonry_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/masonry_controller.ts
|
|
62
|
+
var COLUMNS_PROPERTY = "--stimeo-masonry-columns";
|
|
63
|
+
var MasonryController = class extends Controller {
|
|
64
|
+
static targets = ["item"];
|
|
65
|
+
static values = {
|
|
66
|
+
minColumnWidth: { type: Number, default: 240 },
|
|
67
|
+
gap: { type: Number, default: 16 }
|
|
68
|
+
};
|
|
69
|
+
static events = ["layout"];
|
|
70
|
+
#layout = new LayoutObserver(() => this.#relayout());
|
|
71
|
+
#mutationObserver = null;
|
|
72
|
+
/** Last published column count, so `layout` fires only on real changes. */
|
|
73
|
+
#lastColumns = 0;
|
|
74
|
+
/**
|
|
75
|
+
* Re-pack when a descendant resource finishes loading. Images/iframes report a
|
|
76
|
+
* height of 0 until loaded, which would skew the shortest-column packing if the
|
|
77
|
+
* first pass ran before they settled; `load` does not bubble, so this is bound in
|
|
78
|
+
* the capture phase to catch every descendant.
|
|
79
|
+
*/
|
|
80
|
+
#onLoad = () => this.#relayout();
|
|
81
|
+
/** Observes size/content changes and performs the first layout pass. */
|
|
82
|
+
connect() {
|
|
83
|
+
this.#layout.observe(this.element);
|
|
84
|
+
this.#layout.observeViewport();
|
|
85
|
+
if (typeof MutationObserver !== "undefined") {
|
|
86
|
+
this.#mutationObserver = new MutationObserver(() => this.#relayout());
|
|
87
|
+
this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
|
|
88
|
+
}
|
|
89
|
+
this.element.addEventListener("load", this.#onLoad, true);
|
|
90
|
+
this.#relayout();
|
|
91
|
+
}
|
|
92
|
+
/** Releases both observers and the load listener so nothing fires after detach. */
|
|
93
|
+
disconnect() {
|
|
94
|
+
this.#layout.disconnect();
|
|
95
|
+
this.#mutationObserver?.disconnect();
|
|
96
|
+
this.#mutationObserver = null;
|
|
97
|
+
this.element.removeEventListener("load", this.#onLoad, true);
|
|
98
|
+
this.#lastColumns = 0;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Recomputes the column count and assigns every item to the shortest column.
|
|
102
|
+
* Runs automatically on connect, on resize, on item add/remove, and when a
|
|
103
|
+
* descendant resource loads (private — there is no public action; the observers
|
|
104
|
+
* and the capture-phase `load` listener drive it). Items are walked in DOM
|
|
105
|
+
* order; each lands in the column with the least accumulated height, which
|
|
106
|
+
* keeps the packing balanced without reordering the DOM.
|
|
107
|
+
*/
|
|
108
|
+
#relayout() {
|
|
109
|
+
const items = this.itemTargets;
|
|
110
|
+
const columns = this.#columnCount();
|
|
111
|
+
const heights = new Array(columns).fill(0);
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
let shortest = 0;
|
|
114
|
+
for (let col = 1; col < columns; col++) {
|
|
115
|
+
if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
|
|
116
|
+
}
|
|
117
|
+
item.setAttribute("data-column", String(shortest));
|
|
118
|
+
heights[shortest] = (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;
|
|
119
|
+
}
|
|
120
|
+
this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
|
|
121
|
+
if (columns !== this.#lastColumns) {
|
|
122
|
+
this.#lastColumns = columns;
|
|
123
|
+
this.dispatch("layout", { detail: { columns } });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,
|
|
128
|
+
* never fewer than one. When the width is unmeasurable (detached, or a layout
|
|
129
|
+
* engine that reports `0`), it falls back to a single column so every item still
|
|
130
|
+
* gets a valid `data-column`.
|
|
131
|
+
*/
|
|
132
|
+
#columnCount() {
|
|
133
|
+
const width = this.element.getBoundingClientRect().width;
|
|
134
|
+
const denominator = this.minColumnWidthValue + this.gapValue;
|
|
135
|
+
if (width <= 0 || denominator <= 0) return 1;
|
|
136
|
+
return Math.max(1, Math.floor((width + this.gapValue) / denominator));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export { MasonryController };
|
|
141
|
+
//# sourceMappingURL=masonry_controller.js.map
|
|
142
|
+
//# sourceMappingURL=masonry_controller.js.map
|