stimeo-ui 0.2.0 → 0.3.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 +163 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +313 -0
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +252 -0
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- 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 +246 -0
- data/dist/controllers/date_range_picker_controller.js +441 -0
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/editable_controller.js +169 -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/form_validation_controller.js +1 -1
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/intersection_controller.js +67 -19
- data/dist/controllers/lazy_frame_controller.js +54 -11
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +785 -0
- data/dist/controllers/multi_select_controller.js +755 -0
- data/dist/controllers/navigation_menu_controller.js +511 -0
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +246 -27
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +192 -0
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +238 -0
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +101 -14
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +40 -6
- data/dist/controllers/scrollspy_controller.js +369 -74
- data/dist/controllers/separator_controller.js +96 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +9 -4
- data/dist/controllers/sticky_observer_controller.js +109 -20
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +295 -0
- data/dist/controllers/theme_controller.js +42 -13
- data/dist/controllers/time_picker_controller.js +231 -0
- data/dist/controllers/toast_controller.js +40 -14
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +691 -0
- data/dist/index.js +4256 -915
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +28 -2
|
@@ -7,6 +7,7 @@ var IntersectionWatcher = class {
|
|
|
7
7
|
#onEntries;
|
|
8
8
|
#observer = null;
|
|
9
9
|
#active = false;
|
|
10
|
+
#usingPlatformDefaults = false;
|
|
10
11
|
constructor(onEntries) {
|
|
11
12
|
this.#onEntries = onEntries;
|
|
12
13
|
}
|
|
@@ -14,10 +15,22 @@ var IntersectionWatcher = class {
|
|
|
14
15
|
get active() {
|
|
15
16
|
return this.#active;
|
|
16
17
|
}
|
|
18
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
19
|
+
get usingPlatformDefaults() {
|
|
20
|
+
return this.#usingPlatformDefaults;
|
|
21
|
+
}
|
|
17
22
|
/**
|
|
18
23
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
24
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
25
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
26
|
+
* If initial construction with the configured options fails, the watcher
|
|
27
|
+
* warns and retries once with the same root and platform defaults.
|
|
28
|
+
*
|
|
29
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
30
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
31
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
32
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
33
|
+
* starts from a clean slate.
|
|
21
34
|
*/
|
|
22
35
|
start(targets, options = {}) {
|
|
23
36
|
this.stop();
|
|
@@ -25,31 +38,61 @@ var IntersectionWatcher = class {
|
|
|
25
38
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
39
|
if (list.length === 0) return false;
|
|
27
40
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(entries) => {
|
|
31
|
-
if (this.#active) this.#onEntries(entries);
|
|
32
|
-
}
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
let observer = null;
|
|
42
|
+
try {
|
|
43
|
+
const onEntries = (entries) => {
|
|
44
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
observer = new IntersectionObserver(onEntries, {
|
|
48
|
+
root,
|
|
49
|
+
rootMargin: options.rootMargin,
|
|
50
|
+
threshold: options.threshold
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.warn(
|
|
54
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
55
|
+
error
|
|
56
|
+
);
|
|
57
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
58
|
+
this.#usingPlatformDefaults = true;
|
|
59
|
+
}
|
|
60
|
+
for (const target of list) observer.observe(target);
|
|
61
|
+
this.#observer = observer;
|
|
62
|
+
this.#active = true;
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
observer?.disconnect();
|
|
66
|
+
this.#observer = null;
|
|
67
|
+
this.#active = false;
|
|
68
|
+
this.#usingPlatformDefaults = false;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
37
71
|
}
|
|
38
72
|
/**
|
|
39
73
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
74
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
75
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
76
|
+
*
|
|
77
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
78
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
79
|
*/
|
|
43
80
|
rearm(target) {
|
|
44
81
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
try {
|
|
83
|
+
this.#observer.unobserve(target);
|
|
84
|
+
this.#observer.observe(target);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
this.stop();
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
47
89
|
}
|
|
48
90
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
91
|
stop() {
|
|
50
92
|
this.#active = false;
|
|
51
93
|
this.#observer?.disconnect();
|
|
52
94
|
this.#observer = null;
|
|
95
|
+
this.#usingPlatformDefaults = false;
|
|
53
96
|
}
|
|
54
97
|
};
|
|
55
98
|
|
|
@@ -2,6 +2,45 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/listbox_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/arrow_step.ts
|
|
6
|
+
function isReservedArrowChord(event, allow = []) {
|
|
7
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
8
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/utils/microtask_coalescer.ts
|
|
12
|
+
var MicrotaskCoalescer = class {
|
|
13
|
+
#run;
|
|
14
|
+
#queued = false;
|
|
15
|
+
#active = false;
|
|
16
|
+
#generation = 0;
|
|
17
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
18
|
+
constructor(run) {
|
|
19
|
+
this.#run = run;
|
|
20
|
+
}
|
|
21
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
22
|
+
activate() {
|
|
23
|
+
this.#active = true;
|
|
24
|
+
}
|
|
25
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
26
|
+
cancel() {
|
|
27
|
+
this.#active = false;
|
|
28
|
+
this.#queued = false;
|
|
29
|
+
this.#generation += 1;
|
|
30
|
+
}
|
|
31
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
32
|
+
schedule() {
|
|
33
|
+
if (!this.#active || this.#queued) return;
|
|
34
|
+
this.#queued = true;
|
|
35
|
+
const generation = this.#generation;
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
38
|
+
this.#queued = false;
|
|
39
|
+
this.#run();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
5
44
|
// src/utils/option_scroll.ts
|
|
6
45
|
function scrollOptionIntoView(list, option) {
|
|
7
46
|
if (list.scrollHeight <= list.clientHeight) return;
|
|
@@ -67,27 +106,135 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
67
106
|
}
|
|
68
107
|
};
|
|
69
108
|
|
|
109
|
+
// src/utils/typeahead.ts
|
|
110
|
+
var TYPEAHEAD_RESET_MS = 500;
|
|
111
|
+
var Typeahead = class {
|
|
112
|
+
/** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
|
|
113
|
+
#timers = new SafeTimeout();
|
|
114
|
+
/** Idle window before the query resets, in milliseconds. */
|
|
115
|
+
#resetMs;
|
|
116
|
+
/** The accumulated lowercase query, empty when idle. */
|
|
117
|
+
#query = "";
|
|
118
|
+
/** Id of the pending reset timer, `0` when none is scheduled. */
|
|
119
|
+
#timerId = 0;
|
|
120
|
+
/** @param options - Overrides for the idle window. */
|
|
121
|
+
constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
|
|
122
|
+
this.#resetMs = resetMs;
|
|
123
|
+
}
|
|
124
|
+
/** The query a search would currently run with; empty while idle. */
|
|
125
|
+
get query() {
|
|
126
|
+
return this.#query;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Folds `key` into the query, restarts the idle window, and returns the query to
|
|
130
|
+
* search with. A repeated character collapses the query to that one character.
|
|
131
|
+
*/
|
|
132
|
+
push(key) {
|
|
133
|
+
const char = key.toLowerCase();
|
|
134
|
+
const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
|
|
135
|
+
this.#query = repeated ? char : this.#query + char;
|
|
136
|
+
this.#timers.clear(this.#timerId);
|
|
137
|
+
this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
|
|
138
|
+
return this.#query;
|
|
139
|
+
}
|
|
140
|
+
/** Clears the query and cancels the pending idle reset. */
|
|
141
|
+
reset() {
|
|
142
|
+
this.#query = "";
|
|
143
|
+
this.#timers.clear(this.#timerId);
|
|
144
|
+
this.#timerId = 0;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
function isTypeaheadKey(event) {
|
|
148
|
+
return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing;
|
|
149
|
+
}
|
|
150
|
+
function typeaheadLabel(element, fallbackText) {
|
|
151
|
+
const label = element.getAttribute("aria-label")?.trim();
|
|
152
|
+
if (label) return label.toLowerCase();
|
|
153
|
+
const text = element.textContent ?? "";
|
|
154
|
+
return text.trim().toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel(item)) {
|
|
157
|
+
if (query === "") return -1;
|
|
158
|
+
const count = items.length;
|
|
159
|
+
for (let step = 1; step <= count; step += 1) {
|
|
160
|
+
const index = ((from + step) % count + count) % count;
|
|
161
|
+
const candidate = items[index];
|
|
162
|
+
if (candidate && label(candidate).startsWith(query)) return index;
|
|
163
|
+
}
|
|
164
|
+
return -1;
|
|
165
|
+
}
|
|
166
|
+
|
|
70
167
|
// src/controllers/listbox_controller.ts
|
|
71
|
-
var TYPEAHEAD_TIMEOUT = 500;
|
|
72
168
|
var ListboxController = class extends Controller {
|
|
73
169
|
static targets = ["trigger", "value", "list", "option", "field"];
|
|
74
170
|
static actions = ["close", "onTriggerKeydown", "open", "select", "toggle"];
|
|
75
171
|
static events = ["change"];
|
|
76
|
-
/**
|
|
77
|
-
#
|
|
78
|
-
/**
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
172
|
+
/** Stable ID of the active option; DOM targets are resolved afresh before use. */
|
|
173
|
+
#activeId = null;
|
|
174
|
+
/** Target ID order captured while an option is active, used only for removal fallback. */
|
|
175
|
+
#activeOrder = [];
|
|
176
|
+
#connected = false;
|
|
177
|
+
/** Collapses one mutation batch of target callbacks into a single pass. */
|
|
178
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
|
|
179
|
+
/** Accumulated typeahead query and its idle-reset timer. */
|
|
180
|
+
#typeahead = new Typeahead();
|
|
181
|
+
/** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */
|
|
83
182
|
connect() {
|
|
183
|
+
this.#normalizeSelection();
|
|
84
184
|
this.close();
|
|
85
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
185
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
186
|
+
this.#connected = true;
|
|
187
|
+
this.#reconcile.activate();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Establishes an inactive baseline for a late option, re-resolves active
|
|
191
|
+
* identity, and re-applies the selection baseline.
|
|
192
|
+
*/
|
|
193
|
+
optionTargetConnected(option) {
|
|
194
|
+
option.removeAttribute("data-active");
|
|
195
|
+
if (!this.#connected) return;
|
|
196
|
+
this.#normalizeSelection();
|
|
197
|
+
this.#queueOptionReconciliation();
|
|
198
|
+
}
|
|
199
|
+
/** Removes controller-owned active state and reconciles the surviving targets. */
|
|
200
|
+
optionTargetDisconnected(option) {
|
|
201
|
+
option.removeAttribute("data-active");
|
|
202
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Brings the authored DOM to the shape the APG requires, and derives the state
|
|
206
|
+
* that follows from the initial selection.
|
|
207
|
+
*
|
|
208
|
+
* Three things happen, and only these three — which option is chosen is the
|
|
209
|
+
* author's, and is never changed:
|
|
210
|
+
*
|
|
211
|
+
* 1. Every option gets an explicit value. An absent `aria-selected` means "not
|
|
212
|
+
* selectable" in ARIA, so a forgotten attribute hides a selectable option
|
|
213
|
+
* from assistive technology.
|
|
214
|
+
* 2. At most one stays `true`. The first in DOM order wins, since that is the
|
|
215
|
+
* only deterministic reading of "which one did the author mean".
|
|
216
|
+
* 3. The trigger label and the hidden field are derived from that selection.
|
|
217
|
+
* Without this the widget announces a choice it does not submit: the popup
|
|
218
|
+
* says "Banana", the trigger still says "Choose…", and the form posts "".
|
|
219
|
+
*
|
|
220
|
+
* No `change` fires — nothing changed, this is the initial state being told
|
|
221
|
+
* properly. The scan is the `option` target set: a `role="option"` without the
|
|
222
|
+
* target is outside the contract and is neither counted nor written.
|
|
223
|
+
*/
|
|
224
|
+
#normalizeSelection() {
|
|
225
|
+
const options = this.optionTargets;
|
|
226
|
+
const selected = options.find((option) => option.getAttribute("aria-selected") === "true");
|
|
227
|
+
for (const option of options) {
|
|
228
|
+
option.setAttribute("aria-selected", option === selected ? "true" : "false");
|
|
229
|
+
}
|
|
230
|
+
if (selected) this.#applySelection(selected);
|
|
86
231
|
}
|
|
87
232
|
/** Removes the document listener and clears the typeahead timer. */
|
|
88
233
|
disconnect() {
|
|
89
|
-
|
|
90
|
-
this.#
|
|
234
|
+
this.#connected = false;
|
|
235
|
+
this.#reconcile.cancel();
|
|
236
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
237
|
+
this.#typeahead.reset();
|
|
91
238
|
}
|
|
92
239
|
/**
|
|
93
240
|
* Toggles the list on a real mouse click. Keyboard activation of the
|
|
@@ -102,9 +249,14 @@ var ListboxController = class extends Controller {
|
|
|
102
249
|
this.close();
|
|
103
250
|
}
|
|
104
251
|
}
|
|
105
|
-
/**
|
|
252
|
+
/** Yields claimed keys; otherwise routes the APG select-only keyboard model. */
|
|
106
253
|
onTriggerKeydown(event) {
|
|
107
|
-
|
|
254
|
+
if (event.defaultPrevented) return;
|
|
255
|
+
if (isReservedArrowChord(event)) return;
|
|
256
|
+
if (!this.#isClosed) this.#reconcileActive();
|
|
257
|
+
const options = this.optionTargets;
|
|
258
|
+
const length = options.length;
|
|
259
|
+
const activeIndex = this.#findActiveIndex(options);
|
|
108
260
|
if (this.#isClosed) {
|
|
109
261
|
switch (event.key) {
|
|
110
262
|
case "Enter":
|
|
@@ -123,13 +275,11 @@ var ListboxController = class extends Controller {
|
|
|
123
275
|
switch (event.key) {
|
|
124
276
|
case "ArrowDown":
|
|
125
277
|
event.preventDefault();
|
|
126
|
-
this.#setActive(
|
|
278
|
+
this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);
|
|
127
279
|
break;
|
|
128
280
|
case "ArrowUp":
|
|
129
281
|
event.preventDefault();
|
|
130
|
-
this.#setActive(
|
|
131
|
-
this.#activeIndex < 0 ? length - 1 : (this.#activeIndex - 1 + length) % length
|
|
132
|
-
);
|
|
282
|
+
this.#setActive(activeIndex < 0 ? length - 1 : (activeIndex - 1 + length) % length);
|
|
133
283
|
break;
|
|
134
284
|
case "Home":
|
|
135
285
|
event.preventDefault();
|
|
@@ -145,7 +295,7 @@ var ListboxController = class extends Controller {
|
|
|
145
295
|
this.#commitActive();
|
|
146
296
|
break;
|
|
147
297
|
case "Escape":
|
|
148
|
-
if (event.
|
|
298
|
+
if (event.isComposing) break;
|
|
149
299
|
event.preventDefault();
|
|
150
300
|
this.close();
|
|
151
301
|
this.triggerTarget.focus();
|
|
@@ -154,9 +304,9 @@ var ListboxController = class extends Controller {
|
|
|
154
304
|
this.close();
|
|
155
305
|
break;
|
|
156
306
|
default:
|
|
157
|
-
if (
|
|
307
|
+
if (isTypeaheadKey(event)) {
|
|
158
308
|
event.preventDefault();
|
|
159
|
-
this.#typeaheadTo(event.key);
|
|
309
|
+
this.#typeaheadTo(options, activeIndex, event.key);
|
|
160
310
|
}
|
|
161
311
|
break;
|
|
162
312
|
}
|
|
@@ -164,7 +314,7 @@ var ListboxController = class extends Controller {
|
|
|
164
314
|
/** Selects the clicked option and closes, returning focus to the trigger. */
|
|
165
315
|
select(event) {
|
|
166
316
|
const option = event.currentTarget.closest('[role="option"]');
|
|
167
|
-
if (!option) return;
|
|
317
|
+
if (!option || !this.optionTargets.includes(option)) return;
|
|
168
318
|
this.#selectOption(option);
|
|
169
319
|
this.close();
|
|
170
320
|
this.triggerTarget.focus();
|
|
@@ -189,28 +339,41 @@ var ListboxController = class extends Controller {
|
|
|
189
339
|
this.listTarget.hidden = true;
|
|
190
340
|
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
191
341
|
this.#setActive(-1);
|
|
192
|
-
this.#
|
|
342
|
+
this.#typeahead.reset();
|
|
193
343
|
}
|
|
194
344
|
/** Commits the active option (keyboard) and closes, returning focus. */
|
|
195
345
|
#commitActive() {
|
|
196
|
-
|
|
346
|
+
this.#reconcileActive();
|
|
347
|
+
const options = this.optionTargets;
|
|
348
|
+
const activeIndex = this.#findActiveIndex(options);
|
|
349
|
+
const option = activeIndex < 0 ? void 0 : options[activeIndex];
|
|
197
350
|
if (option) this.#selectOption(option);
|
|
198
351
|
this.close();
|
|
199
352
|
this.triggerTarget.focus();
|
|
200
353
|
}
|
|
201
354
|
/** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */
|
|
202
355
|
#selectOption(option) {
|
|
356
|
+
const { value, fieldChanged } = this.#applySelection(option);
|
|
357
|
+
if (fieldChanged) {
|
|
358
|
+
this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
|
|
359
|
+
}
|
|
360
|
+
this.dispatch("change", { detail: { value, option } });
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Writes `option` into `aria-selected`, the trigger label and the hidden field.
|
|
364
|
+
* Emits nothing — {@link #normalizeSelection} reuses this at connect, where the
|
|
365
|
+
* state is being described rather than changed.
|
|
366
|
+
*/
|
|
367
|
+
#applySelection(option) {
|
|
203
368
|
for (const candidate of this.optionTargets) {
|
|
204
369
|
candidate.setAttribute("aria-selected", candidate === option ? "true" : "false");
|
|
205
370
|
}
|
|
206
371
|
const label = (option.textContent ?? "").trim();
|
|
207
372
|
const value = option.dataset.value ?? label;
|
|
208
373
|
if (this.hasValueTarget) this.valueTarget.textContent = label;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
this.dispatch("change", { detail: { value, option } });
|
|
374
|
+
const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;
|
|
375
|
+
if (fieldChanged) this.fieldTarget.value = value;
|
|
376
|
+
return { value, fieldChanged };
|
|
214
377
|
}
|
|
215
378
|
/**
|
|
216
379
|
* Marks the option at `index` active via `data-active` and the trigger's
|
|
@@ -218,47 +381,88 @@ var ListboxController = class extends Controller {
|
|
|
218
381
|
* set to empty, per the APG).
|
|
219
382
|
*/
|
|
220
383
|
#setActive(index) {
|
|
221
|
-
|
|
222
|
-
const active = index < 0 ? null :
|
|
223
|
-
for (const option of
|
|
384
|
+
const options = this.optionTargets;
|
|
385
|
+
const active = index < 0 ? null : options[index] ?? null;
|
|
386
|
+
for (const option of options) {
|
|
387
|
+
const marked = option.hasAttribute("data-active");
|
|
224
388
|
if (option === active) {
|
|
225
|
-
option.setAttribute("data-active", "");
|
|
226
|
-
} else {
|
|
389
|
+
if (!marked) option.setAttribute("data-active", "");
|
|
390
|
+
} else if (marked) {
|
|
227
391
|
option.removeAttribute("data-active");
|
|
228
392
|
}
|
|
229
393
|
}
|
|
230
394
|
if (active?.id) {
|
|
395
|
+
this.#activeId = active.id;
|
|
231
396
|
this.triggerTarget.setAttribute("aria-activedescendant", active.id);
|
|
232
397
|
} else {
|
|
398
|
+
this.#activeId = null;
|
|
233
399
|
this.triggerTarget.removeAttribute("aria-activedescendant");
|
|
234
400
|
}
|
|
401
|
+
this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];
|
|
235
402
|
if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);
|
|
236
403
|
}
|
|
237
|
-
/**
|
|
238
|
-
#
|
|
239
|
-
this
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
404
|
+
/** Resolves active state against the current target collection. */
|
|
405
|
+
#reconcileActive() {
|
|
406
|
+
if (!this.hasTriggerTarget || this.#isClosed) {
|
|
407
|
+
if (this.hasTriggerTarget) this.#setActive(-1);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const options = this.optionTargets;
|
|
411
|
+
const currentIndex = this.#findActiveIndex(options);
|
|
412
|
+
if (currentIndex >= 0) {
|
|
413
|
+
const active = options[currentIndex] ?? null;
|
|
414
|
+
const marked = options.filter((option) => option.hasAttribute("data-active"));
|
|
415
|
+
const stateMatches = this.#activeId === (active?.id || null) && marked.length === 1 && marked[0] === active && this.triggerTarget.getAttribute("aria-activedescendant") === (active?.id || null);
|
|
416
|
+
if (stateMatches) {
|
|
417
|
+
this.#activeOrder = options.map((option) => option.id).filter(Boolean);
|
|
418
|
+
} else {
|
|
419
|
+
this.#setActive(currentIndex);
|
|
420
|
+
}
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const activeId = this.triggerTarget.getAttribute("aria-activedescendant") ?? this.#activeId;
|
|
424
|
+
this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);
|
|
425
|
+
}
|
|
426
|
+
/** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */
|
|
427
|
+
#findActiveIndex(options) {
|
|
428
|
+
const activeId = (this.hasTriggerTarget ? this.triggerTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
|
|
429
|
+
if (activeId) return options.findIndex((option) => option.id === activeId);
|
|
430
|
+
return options.findIndex((option) => option.hasAttribute("data-active"));
|
|
248
431
|
}
|
|
249
|
-
/**
|
|
250
|
-
#
|
|
251
|
-
this.#
|
|
252
|
-
|
|
432
|
+
/** Chooses a surviving former successor, then a former predecessor. */
|
|
433
|
+
#findFallbackIndex(options, activeId) {
|
|
434
|
+
const oldIndex = this.#activeOrder.indexOf(activeId);
|
|
435
|
+
if (oldIndex < 0) return -1;
|
|
436
|
+
const indexesById = new Map(options.map((option, index) => [option.id, index]));
|
|
437
|
+
for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
|
|
438
|
+
const fallback = indexesById.get(this.#activeOrder[index] ?? "");
|
|
439
|
+
if (fallback !== void 0) return fallback;
|
|
440
|
+
}
|
|
441
|
+
for (let index = oldIndex - 1; index >= 0; index -= 1) {
|
|
442
|
+
const fallback = indexesById.get(this.#activeOrder[index] ?? "");
|
|
443
|
+
if (fallback !== void 0) return fallback;
|
|
444
|
+
}
|
|
445
|
+
return -1;
|
|
253
446
|
}
|
|
254
|
-
/**
|
|
447
|
+
/** Coalesces all target callbacks from one MutationObserver batch. */
|
|
448
|
+
#queueOptionReconciliation() {
|
|
449
|
+
this.#reconcile.schedule();
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Advances the typeahead query and activates the next matching option.
|
|
453
|
+
*
|
|
454
|
+
* The search resumes just after the active option so repeating a character
|
|
455
|
+
* cycles through the options starting with it, rather than re-activating the
|
|
456
|
+
* same first match on every press.
|
|
457
|
+
*/
|
|
458
|
+
#typeaheadTo(options, activeIndex, char) {
|
|
459
|
+
const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));
|
|
460
|
+
if (index !== -1) this.#setActive(index);
|
|
461
|
+
}
|
|
462
|
+
/** Closes on an outside click before an inside handler can detach its target. */
|
|
255
463
|
#onOutsideClick = (event) => {
|
|
256
464
|
if (!this.#isClosed && !this.element.contains(event.target)) this.close();
|
|
257
465
|
};
|
|
258
|
-
/** Whether `event.key` is a single printable character (no modifier chord). */
|
|
259
|
-
#isPrintable(event) {
|
|
260
|
-
return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
261
|
-
}
|
|
262
466
|
/** Whether the list is currently hidden. */
|
|
263
467
|
get #isClosed() {
|
|
264
468
|
return !this.hasListTarget || this.listTarget.hidden !== false;
|
|
@@ -32,8 +32,8 @@ var LocalTimeController = class extends Controller {
|
|
|
32
32
|
return Number.isNaN(ms) ? null : new Date(ms);
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
* Reads a timezone-less date-time as UTC — the
|
|
36
|
-
* since `Date.parse` would otherwise
|
|
35
|
+
* Reads a timezone-less date-time as UTC — the input contract of this
|
|
36
|
+
* controller — since `Date.parse` would otherwise read `"2026-06-08T12:30:00"` in
|
|
37
37
|
* the *runtime's* local zone, contradicting "the server emits UTC". Values that
|
|
38
38
|
* already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
|
|
39
39
|
* parsed as UTC) are returned unchanged.
|
|
@@ -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
|