stimeo-ui 0.16.0 → 0.18.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 +136 -0
- data/README.md +27 -11
- data/dist/cable/index.js +384 -449
- data/dist/controllers/accordion_controller.js +174 -55
- data/dist/controllers/alert_dialog_controller.js +903 -235
- data/dist/controllers/announcer_controller.js +238 -205
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +414 -145
- data/dist/controllers/avatar_controller.js +185 -107
- data/dist/controllers/breadcrumb_controller.js +0 -130
- data/dist/controllers/bulk_select_controller.js +282 -58
- data/dist/controllers/calendar_controller.js +228 -260
- data/dist/controllers/carousel_controller.js +536 -436
- data/dist/controllers/character_counter_controller.js +493 -207
- data/dist/controllers/checkbox_controller.js +38 -74
- data/dist/controllers/clipboard_controller.js +261 -149
- data/dist/controllers/collapsible_controller.js +304 -120
- data/dist/controllers/color_picker_controller.js +178 -146
- data/dist/controllers/combobox_controller.js +522 -156
- data/dist/controllers/command_palette_controller.js +1156 -395
- data/dist/controllers/conditional_fields_controller.js +300 -171
- data/dist/controllers/confirm_controller.js +980 -262
- data/dist/controllers/context_menu_controller.js +346 -153
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +281 -158
- data/dist/controllers/currency_input_controller.js +575 -225
- data/dist/controllers/data_grid_controller.js +248 -214
- data/dist/controllers/date_range_picker_controller.js +338 -235
- data/dist/controllers/dialog_controller.js +899 -237
- data/dist/controllers/direct_upload_controller.js +97 -167
- data/dist/controllers/dirty_form_controller.js +72 -69
- data/dist/controllers/dismissible_controller.js +196 -24
- data/dist/controllers/drawer_controller.js +974 -355
- data/dist/controllers/dropdown_controller.js +339 -96
- data/dist/controllers/editable_controller.js +315 -88
- data/dist/controllers/empty_state_controller.js +79 -58
- data/dist/controllers/file_dropzone_controller.js +322 -211
- data/dist/controllers/filter_controller.js +77 -42
- data/dist/controllers/flash_controller.js +238 -347
- data/dist/controllers/focus_controller.js +679 -240
- data/dist/controllers/form_field_controller.js +156 -132
- data/dist/controllers/form_validation_controller.js +31 -97
- data/dist/controllers/frame_loading_controller.js +275 -217
- data/dist/controllers/highlight_controller.js +107 -75
- data/dist/controllers/hover_card_controller.js +327 -130
- data/dist/controllers/idle_controller.js +271 -101
- data/dist/controllers/input_mask_controller.js +483 -97
- data/dist/controllers/intersection_controller.js +150 -106
- data/dist/controllers/lazy_frame_controller.js +24 -68
- data/dist/controllers/listbox_controller.js +523 -240
- data/dist/controllers/local_time_controller.js +102 -77
- data/dist/controllers/masonry_controller.js +80 -96
- data/dist/controllers/menu_controller.js +302 -188
- data/dist/controllers/menubar_controller.js +232 -345
- data/dist/controllers/meter_controller.js +417 -87
- data/dist/controllers/multi_select_controller.js +785 -393
- data/dist/controllers/navigation_menu_controller.js +190 -250
- data/dist/controllers/nested_form_controller.js +396 -192
- data/dist/controllers/network_status_controller.js +268 -62
- data/dist/controllers/number_input_controller.js +610 -303
- data/dist/controllers/optimistic_controller.js +45 -87
- data/dist/controllers/otp_controller.js +658 -255
- data/dist/controllers/overflow_indicator_controller.js +122 -113
- data/dist/controllers/overflow_menu_controller.js +470 -295
- data/dist/controllers/pagination_controller.js +332 -123
- data/dist/controllers/password_reveal_controller.js +392 -121
- data/dist/controllers/password_strength_controller.js +111 -158
- data/dist/controllers/persist_controller.js +165 -111
- data/dist/controllers/pointer_drag_controller.js +434 -188
- data/dist/controllers/popover_controller.js +269 -113
- data/dist/controllers/portal_controller.js +215 -76
- data/dist/controllers/preview_guard_controller.js +243 -116
- data/dist/controllers/progress_controller.js +391 -78
- data/dist/controllers/radio_group_controller.js +278 -211
- data/dist/controllers/range_slider_controller.js +220 -113
- data/dist/controllers/rating_controller.js +458 -211
- data/dist/controllers/read_more_controller.js +138 -63
- data/dist/controllers/reading_progress_controller.js +166 -137
- data/dist/controllers/relative_time_controller.js +211 -112
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
- data/dist/controllers/resizable_controller.js +299 -150
- data/dist/controllers/roving_controller.js +49 -88
- data/dist/controllers/scroll_area_controller.js +490 -246
- data/dist/controllers/scroll_restore_controller.js +14 -55
- data/dist/controllers/scroll_visibility_controller.js +432 -149
- data/dist/controllers/scrollspy_controller.js +193 -248
- data/dist/controllers/separator_controller.js +285 -142
- data/dist/controllers/sidebar_controller.js +1069 -404
- data/dist/controllers/skeleton_controller.js +259 -101
- data/dist/controllers/slider_controller.js +187 -86
- data/dist/controllers/smart_sticky_header_controller.js +173 -66
- data/dist/controllers/sortable_controller.js +26 -125
- data/dist/controllers/spinner_controller.js +288 -173
- data/dist/controllers/step_indicator_controller.js +105 -46
- data/dist/controllers/stepper_controller.js +120 -73
- data/dist/controllers/stick_to_bottom_controller.js +275 -140
- data/dist/controllers/sticky_observer_controller.js +220 -49
- data/dist/controllers/submit_once_controller.js +376 -227
- data/dist/controllers/switch_controller.js +201 -84
- data/dist/controllers/tabs_controller.js +41 -30
- data/dist/controllers/tags_input_controller.js +293 -173
- data/dist/controllers/textarea_autosize_controller.js +108 -74
- data/dist/controllers/theme_controller.js +140 -116
- data/dist/controllers/time_picker_controller.js +331 -127
- data/dist/controllers/toast_controller.js +244 -324
- data/dist/controllers/toggle_group_controller.js +252 -191
- data/dist/controllers/toolbar_controller.js +219 -120
- data/dist/controllers/tooltip_controller.js +158 -124
- data/dist/controllers/transition_controller.js +123 -119
- data/dist/controllers/tree_view_controller.js +283 -369
- data/dist/index.js +8573 -10410
- data/dist/positioning/index.js +154 -47
- data/lib/generators/stimeo/install/install_generator.rb +7 -2
- data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
|
@@ -1,8 +1,45 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
var DATETIME = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
4
|
+
function readDatetime(element) {
|
|
5
|
+
const raw = element.getAttribute("datetime");
|
|
6
|
+
if (raw === null) return null;
|
|
7
|
+
const match = DATETIME.exec(raw.trim());
|
|
8
|
+
if (match === null) return null;
|
|
9
|
+
const [
|
|
10
|
+
,
|
|
11
|
+
yearText,
|
|
12
|
+
monthText,
|
|
13
|
+
dayText,
|
|
14
|
+
hourText = "00",
|
|
15
|
+
minuteText = "00",
|
|
16
|
+
secondText = "00",
|
|
17
|
+
fraction = "",
|
|
18
|
+
zone = "Z"
|
|
19
|
+
] = match;
|
|
20
|
+
const year = Number(yearText);
|
|
21
|
+
const month = Number(monthText);
|
|
22
|
+
const day = Number(dayText);
|
|
23
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
24
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
25
|
+
if (month < 1 || month > 12 || day < 1 || day > (days[month - 1] ?? 0)) return null;
|
|
26
|
+
const hour = Number(hourText);
|
|
27
|
+
const minute = Number(minuteText);
|
|
28
|
+
const second = Number(secondText);
|
|
29
|
+
if (hour === 24 && (minute !== 0 || second !== 0 || /[1-9]/.test(fraction))) return null;
|
|
30
|
+
let offset = zone;
|
|
31
|
+
if (zone !== "Z") {
|
|
32
|
+
const digits = zone.slice(1).replace(":", "");
|
|
33
|
+
const offsetHour = digits.slice(0, 2);
|
|
34
|
+
const offsetMinute = digits.slice(2);
|
|
35
|
+
offset = `${zone[0]}${offsetHour}:${offsetMinute}`;
|
|
36
|
+
}
|
|
37
|
+
const milliseconds = fraction.slice(0, 3).padEnd(3, "0");
|
|
38
|
+
const canonical = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}.${milliseconds}${offset}`;
|
|
39
|
+
const instant = Date.parse(canonical);
|
|
40
|
+
return Number.isNaN(instant) ? null : instant;
|
|
41
|
+
}
|
|
4
42
|
|
|
5
|
-
// src/utils/intl_format.ts
|
|
6
43
|
var formatters = /* @__PURE__ */ new WeakMap();
|
|
7
44
|
function intlFormatter(factory, locale, options, fallbackLocale) {
|
|
8
45
|
const cache = cacheFor(factory);
|
|
@@ -31,32 +68,40 @@ function construct(factory, locale, options, fallbackLocale) {
|
|
|
31
68
|
}
|
|
32
69
|
}
|
|
33
70
|
|
|
34
|
-
// src/utils/locale.ts
|
|
35
71
|
function resolveLocale(element, declared = "") {
|
|
36
72
|
return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
37
73
|
}
|
|
38
74
|
|
|
39
|
-
|
|
75
|
+
var ListenerSet = class {
|
|
76
|
+
#abort = new AbortController();
|
|
77
|
+
add(target, type, handler, options) {
|
|
78
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
79
|
+
}
|
|
80
|
+
dispose() {
|
|
81
|
+
this.#abort.abort();
|
|
82
|
+
this.#abort = new AbortController();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
40
86
|
var MicrotaskCoalescer = class {
|
|
41
87
|
#run;
|
|
42
88
|
#queued = false;
|
|
43
89
|
#active = false;
|
|
44
90
|
#generation = 0;
|
|
45
|
-
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
46
91
|
constructor(run) {
|
|
47
92
|
this.#run = run;
|
|
48
93
|
}
|
|
49
|
-
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
50
94
|
activate() {
|
|
51
95
|
this.#active = true;
|
|
52
96
|
}
|
|
53
|
-
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
54
97
|
cancel() {
|
|
55
98
|
this.#active = false;
|
|
56
99
|
this.#queued = false;
|
|
57
100
|
this.#generation += 1;
|
|
58
101
|
}
|
|
59
|
-
|
|
102
|
+
get pending() {
|
|
103
|
+
return this.#queued;
|
|
104
|
+
}
|
|
60
105
|
schedule() {
|
|
61
106
|
if (!this.#active || this.#queued) return;
|
|
62
107
|
this.#queued = true;
|
|
@@ -69,7 +114,34 @@ var MicrotaskCoalescer = class {
|
|
|
69
114
|
}
|
|
70
115
|
};
|
|
71
116
|
|
|
72
|
-
|
|
117
|
+
var MorphRenderWatcher = class {
|
|
118
|
+
#listeners = new ListenerSet();
|
|
119
|
+
#pass;
|
|
120
|
+
#element = null;
|
|
121
|
+
#onMorph = () => this.#pass.schedule();
|
|
122
|
+
constructor(run) {
|
|
123
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
124
|
+
}
|
|
125
|
+
observe(element) {
|
|
126
|
+
if (this.#element === element) return;
|
|
127
|
+
this.disconnect();
|
|
128
|
+
this.#element = element;
|
|
129
|
+
this.#pass.activate();
|
|
130
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
131
|
+
}
|
|
132
|
+
schedule() {
|
|
133
|
+
this.#pass.schedule();
|
|
134
|
+
}
|
|
135
|
+
get pending() {
|
|
136
|
+
return this.#pass.pending;
|
|
137
|
+
}
|
|
138
|
+
disconnect() {
|
|
139
|
+
this.#listeners.dispose();
|
|
140
|
+
this.#pass.cancel();
|
|
141
|
+
this.#element = null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
73
145
|
var STYLES = /* @__PURE__ */ new Set(["full", "long", "medium", "short"]);
|
|
74
146
|
function toStyle(value) {
|
|
75
147
|
return STYLES.has(value) ? value : void 0;
|
|
@@ -83,55 +155,48 @@ var LocalTimeController = class extends Controller {
|
|
|
83
155
|
titleFormat: { type: String, default: "" }
|
|
84
156
|
};
|
|
85
157
|
static events = ["format"];
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
158
|
+
#resync = new MorphRenderWatcher(() => {
|
|
159
|
+
const datetimeChanged = this.#datetimeWatch.takeRecords().length > 0;
|
|
160
|
+
const notify = this.#formatPending || datetimeChanged;
|
|
161
|
+
this.#formatPending = false;
|
|
162
|
+
this.#render(notify);
|
|
163
|
+
});
|
|
164
|
+
#formatPending = false;
|
|
92
165
|
#datetimeWatch = new MutationObserver(() => {
|
|
166
|
+
this.#formatPending = true;
|
|
93
167
|
this.#resync.schedule();
|
|
94
168
|
});
|
|
95
169
|
connect() {
|
|
96
|
-
this.#resync.
|
|
170
|
+
this.#resync.observe(this.element);
|
|
97
171
|
this.#datetimeWatch.observe(this.element, { attributeFilter: ["datetime"] });
|
|
172
|
+
this.#formatPending = false;
|
|
98
173
|
this.#render();
|
|
99
174
|
}
|
|
100
175
|
disconnect() {
|
|
101
|
-
this.#resync.
|
|
176
|
+
this.#resync.disconnect();
|
|
102
177
|
this.#datetimeWatch.disconnect();
|
|
103
178
|
}
|
|
104
|
-
/** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
|
|
105
179
|
localeValueChanged() {
|
|
180
|
+
this.#formatPending = true;
|
|
106
181
|
this.#resync.schedule();
|
|
107
182
|
}
|
|
108
|
-
/** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
|
|
109
183
|
timeZoneValueChanged() {
|
|
184
|
+
this.#formatPending = true;
|
|
110
185
|
this.#resync.schedule();
|
|
111
186
|
}
|
|
112
|
-
/** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
|
|
113
187
|
dateStyleValueChanged() {
|
|
188
|
+
this.#formatPending = true;
|
|
114
189
|
this.#resync.schedule();
|
|
115
190
|
}
|
|
116
|
-
/** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
|
|
117
191
|
timeStyleValueChanged() {
|
|
192
|
+
this.#formatPending = true;
|
|
118
193
|
this.#resync.schedule();
|
|
119
194
|
}
|
|
120
|
-
/** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
|
|
121
195
|
titleFormatValueChanged() {
|
|
196
|
+
this.#formatPending = true;
|
|
122
197
|
this.#resync.schedule();
|
|
123
198
|
}
|
|
124
|
-
|
|
125
|
-
* Formats the instant in `datetime` against the current Values and writes it out.
|
|
126
|
-
*
|
|
127
|
-
* The `format` event rides with every pass, including a repaint a morph triggers:
|
|
128
|
-
* its condition is that formatting was applied, and a repaint applies it with a
|
|
129
|
-
* new result. A pass that cannot format writes nothing and emits nothing, so the
|
|
130
|
-
* authored absolute text stays as the fallback.
|
|
131
|
-
*
|
|
132
|
-
* @stimeoRenderRoot
|
|
133
|
-
*/
|
|
134
|
-
#render() {
|
|
199
|
+
#render(notify = true) {
|
|
135
200
|
const date = this.#parse();
|
|
136
201
|
if (date === null) return;
|
|
137
202
|
const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
|
|
@@ -139,55 +204,16 @@ var LocalTimeController = class extends Controller {
|
|
|
139
204
|
this.element.textContent = formatted;
|
|
140
205
|
const title = this.#title(date);
|
|
141
206
|
if (title !== null) this.element.setAttribute("title", title);
|
|
142
|
-
this.dispatch("format", { detail: { formatted } });
|
|
207
|
+
if (notify) this.dispatch("format", { detail: { formatted } });
|
|
143
208
|
}
|
|
144
|
-
/**
|
|
145
|
-
* Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace
|
|
146
|
-
* around the attribute value is tolerated.
|
|
147
|
-
*/
|
|
148
209
|
#parse() {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return Number.isNaN(ms) ? null : new Date(ms);
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Reads a timezone-less date-time as UTC — the input contract of this
|
|
156
|
-
* controller — since `Date.parse` would otherwise read `"2026-06-08T12:30:00"` in
|
|
157
|
-
* the *runtime's* local zone, contradicting "the server emits UTC". Values that
|
|
158
|
-
* already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
|
|
159
|
-
* parsed as UTC) are returned unchanged.
|
|
160
|
-
*
|
|
161
|
-
* HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is
|
|
162
|
-
* left to each engine, so a whole value shaped that way is normalized to the `T`
|
|
163
|
-
* separator first. The pattern is anchored: a value trailing anything else — a
|
|
164
|
-
* zone word such as `"2026-06-08 12:30:00 UTC"` — is handed to `Date.parse` as
|
|
165
|
-
* authored instead of being turned into a string nothing can parse.
|
|
166
|
-
*/
|
|
167
|
-
#asUtc(value) {
|
|
168
|
-
const isoLike = value.replace(
|
|
169
|
-
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$/,
|
|
170
|
-
"$1T$2"
|
|
171
|
-
);
|
|
172
|
-
const hasTime = /T\d{2}:\d{2}/.test(isoLike);
|
|
173
|
-
const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(isoLike);
|
|
174
|
-
return hasTime && !hasZone ? `${isoLike}Z` : isoLike;
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Builds the optional detailed `title`. `titleFormat` is an `Intl` style
|
|
178
|
-
* keyword applied to *both* date and time; empty (the default) adds no title.
|
|
179
|
-
*/
|
|
210
|
+
const instant = readDatetime(this.element);
|
|
211
|
+
return instant === null ? null : new Date(instant);
|
|
212
|
+
}
|
|
180
213
|
#title(date) {
|
|
181
214
|
if (this.titleFormatValue.length === 0) return null;
|
|
182
215
|
return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);
|
|
183
216
|
}
|
|
184
|
-
/**
|
|
185
|
-
* Formats `date` with `Intl.DateTimeFormat`, including each style only when it
|
|
186
|
-
* is a valid keyword (so a consumer can show date-only or time-only by clearing
|
|
187
|
-
* the other). Returns `null` when neither style is usable or the declaration
|
|
188
|
-
* names a locale or time zone `Intl` rejects, so the caller can leave the
|
|
189
|
-
* authored text untouched.
|
|
190
|
-
*/
|
|
191
217
|
#applyFormat(date, dateStyle, timeStyle) {
|
|
192
218
|
const options = {
|
|
193
219
|
dateStyle: toStyle(dateStyle),
|
|
@@ -205,5 +231,4 @@ var LocalTimeController = class extends Controller {
|
|
|
205
231
|
};
|
|
206
232
|
|
|
207
233
|
export { LocalTimeController };
|
|
208
|
-
//# sourceMappingURL=local_time_controller.js.map
|
|
209
234
|
//# sourceMappingURL=local_time_controller.js.map
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
function setAttributeIfChanged(element, name, value, beforeWrite) {
|
|
4
|
+
if (element.getAttribute(name) === value) return false;
|
|
5
|
+
if (value === null) element.removeAttribute(name);
|
|
6
|
+
else element.setAttribute(name, value);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
4
9
|
|
|
5
|
-
// src/utils/layout_observer.ts
|
|
6
10
|
var LayoutObserver = class {
|
|
7
11
|
#callback;
|
|
8
12
|
#resizeObserverFactory;
|
|
9
13
|
#resizeObserver = null;
|
|
10
14
|
#observingViewport = false;
|
|
11
15
|
#loadContainer = null;
|
|
12
|
-
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
13
16
|
#handleViewportResize = () => {
|
|
14
17
|
this.#callback();
|
|
15
18
|
};
|
|
16
|
-
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
17
19
|
#handleDescendantLoad = () => {
|
|
18
20
|
this.#callback();
|
|
19
21
|
};
|
|
@@ -21,11 +23,6 @@ var LayoutObserver = class {
|
|
|
21
23
|
this.#callback = callback;
|
|
22
24
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
23
25
|
}
|
|
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
26
|
observe(element) {
|
|
30
27
|
if (!this.#resizeObserverFactory) return;
|
|
31
28
|
if (!this.#resizeObserver) {
|
|
@@ -35,47 +32,28 @@ var LayoutObserver = class {
|
|
|
35
32
|
}
|
|
36
33
|
this.#resizeObserver.observe(element);
|
|
37
34
|
}
|
|
38
|
-
/** Stops observing a single element while leaving any others in place. */
|
|
39
35
|
unobserve(element) {
|
|
40
36
|
this.#resizeObserver?.unobserve(element);
|
|
41
37
|
}
|
|
42
|
-
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
43
38
|
observeViewport() {
|
|
44
39
|
if (this.#observingViewport) return;
|
|
45
40
|
this.#observingViewport = true;
|
|
46
41
|
window.addEventListener("resize", this.#handleViewportResize);
|
|
47
42
|
}
|
|
48
|
-
/** Stops observing viewport resizes without affecting element observation. */
|
|
49
43
|
unobserveViewport() {
|
|
50
44
|
if (!this.#observingViewport) return;
|
|
51
45
|
this.#observingViewport = false;
|
|
52
46
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
53
47
|
}
|
|
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
48
|
observeDescendantLoads(container) {
|
|
65
49
|
this.unobserveDescendantLoads();
|
|
66
50
|
this.#loadContainer = container;
|
|
67
51
|
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
68
52
|
}
|
|
69
|
-
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
70
53
|
unobserveDescendantLoads() {
|
|
71
54
|
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
72
55
|
this.#loadContainer = null;
|
|
73
56
|
}
|
|
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
57
|
disconnect() {
|
|
80
58
|
this.#resizeObserver?.disconnect();
|
|
81
59
|
this.#resizeObserver = null;
|
|
@@ -84,27 +62,25 @@ var LayoutObserver = class {
|
|
|
84
62
|
}
|
|
85
63
|
};
|
|
86
64
|
|
|
87
|
-
// src/utils/microtask_coalescer.ts
|
|
88
65
|
var MicrotaskCoalescer = class {
|
|
89
66
|
#run;
|
|
90
67
|
#queued = false;
|
|
91
68
|
#active = false;
|
|
92
69
|
#generation = 0;
|
|
93
|
-
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
94
70
|
constructor(run) {
|
|
95
71
|
this.#run = run;
|
|
96
72
|
}
|
|
97
|
-
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
98
73
|
activate() {
|
|
99
74
|
this.#active = true;
|
|
100
75
|
}
|
|
101
|
-
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
102
76
|
cancel() {
|
|
103
77
|
this.#active = false;
|
|
104
78
|
this.#queued = false;
|
|
105
79
|
this.#generation += 1;
|
|
106
80
|
}
|
|
107
|
-
|
|
81
|
+
get pending() {
|
|
82
|
+
return this.#queued;
|
|
83
|
+
}
|
|
108
84
|
schedule() {
|
|
109
85
|
if (!this.#active || this.#queued) return;
|
|
110
86
|
this.#queued = true;
|
|
@@ -117,69 +93,87 @@ var MicrotaskCoalescer = class {
|
|
|
117
93
|
}
|
|
118
94
|
};
|
|
119
95
|
|
|
120
|
-
|
|
96
|
+
var NUMBER_BOUNDS = {
|
|
97
|
+
finite: { finite: true }};
|
|
98
|
+
function matchesNumberBounds(value, bounds) {
|
|
99
|
+
if (!Number.isFinite(value)) {
|
|
100
|
+
const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
|
|
101
|
+
if (direction === null) return false;
|
|
102
|
+
if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
|
|
103
|
+
}
|
|
104
|
+
if (bounds.min !== void 0 && value < bounds.min) return false;
|
|
105
|
+
if (bounds.max !== void 0 && value > bounds.max) return false;
|
|
106
|
+
if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
|
|
107
|
+
if (bounds.integer && !Number.isInteger(value)) return false;
|
|
108
|
+
if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readNumber(raw, fallback, bounds) {
|
|
113
|
+
return matchesNumberBounds(raw, bounds) ? raw : fallback;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
var NumberValueReader = class {
|
|
117
|
+
#lastRejected = /* @__PURE__ */ new Map();
|
|
118
|
+
read(owner, name, raw, fallback, bounds) {
|
|
119
|
+
const resolved = readNumber(raw, fallback, bounds);
|
|
120
|
+
if (matchesNumberBounds(raw, bounds)) {
|
|
121
|
+
this.#lastRejected.delete(name);
|
|
122
|
+
return resolved;
|
|
123
|
+
}
|
|
124
|
+
const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
|
|
125
|
+
const literal = owner.element.getAttribute(attribute);
|
|
126
|
+
if (literal === null) {
|
|
127
|
+
this.#lastRejected.delete(name);
|
|
128
|
+
return resolved;
|
|
129
|
+
}
|
|
130
|
+
if (this.#lastRejected.get(name) !== literal) {
|
|
131
|
+
this.#lastRejected.set(name, literal);
|
|
132
|
+
console.warn(
|
|
133
|
+
`Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return resolved;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
121
140
|
var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
|
|
122
141
|
var DEFAULT_MIN_COLUMN_WIDTH = 240;
|
|
123
142
|
var DEFAULT_GAP = 16;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
var MasonryController = class extends Controller {
|
|
143
|
+
var MasonryController = class _MasonryController extends Controller {
|
|
144
|
+
#numbers = new NumberValueReader();
|
|
128
145
|
static targets = ["item"];
|
|
129
146
|
static values = {
|
|
130
147
|
minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
|
|
131
148
|
gap: { type: Number, default: DEFAULT_GAP }
|
|
132
149
|
};
|
|
150
|
+
static valueConstraints = {
|
|
151
|
+
minColumnWidth: NUMBER_BOUNDS.finite,
|
|
152
|
+
gap: NUMBER_BOUNDS.finite
|
|
153
|
+
};
|
|
133
154
|
static events = ["layout"];
|
|
134
|
-
/**
|
|
135
|
-
* The declared numbers after validation, so the layout path never sees a value
|
|
136
|
-
* it cannot compute with. Both are resolved once per declaration change rather
|
|
137
|
-
* than on every pass.
|
|
138
|
-
*/
|
|
139
155
|
#minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;
|
|
140
156
|
#gap = DEFAULT_GAP;
|
|
141
|
-
/**
|
|
142
|
-
* Collapses every re-layout trigger of one DOM mutation into a single pass, and
|
|
143
|
-
* refuses to run before `connect()` or after `disconnect()`.
|
|
144
|
-
*
|
|
145
|
-
* The triggers arrive in bursts — a resize stream, a morph that syncs several
|
|
146
|
-
* attributes, a batch of rows — and each pass measures every item, so folding
|
|
147
|
-
* them keeps the work proportional to the batch rather than to the events in it.
|
|
148
|
-
*/
|
|
149
157
|
#reconcile = new MicrotaskCoalescer(() => this.#relayout());
|
|
150
|
-
/** Items that left the target set and still carry the column hook. */
|
|
151
158
|
#released = /* @__PURE__ */ new Set();
|
|
152
159
|
#layout = new LayoutObserver(() => this.#reconcile.schedule());
|
|
153
160
|
#mutationObserver = null;
|
|
154
|
-
/** Last published column count, so `layout` fires only on real changes. */
|
|
155
161
|
#lastColumns = 0;
|
|
156
|
-
/** Resolves the declared column width once, falling back when it is unreadable. */
|
|
157
162
|
minColumnWidthValueChanged() {
|
|
158
|
-
this.#minColumnWidth =
|
|
163
|
+
this.#minColumnWidth = this.#safeMinColumnWidth;
|
|
159
164
|
this.#reconcile.schedule();
|
|
160
165
|
}
|
|
161
|
-
/** Resolves the declared gap once, falling back when it is unreadable. */
|
|
162
166
|
gapValueChanged() {
|
|
163
|
-
this.#gap =
|
|
167
|
+
this.#gap = this.#safeGap;
|
|
164
168
|
this.#reconcile.schedule();
|
|
165
169
|
}
|
|
166
|
-
/** Packs an element that became an item without moving in the DOM. */
|
|
167
170
|
itemTargetConnected() {
|
|
168
171
|
this.#reconcile.schedule();
|
|
169
172
|
}
|
|
170
|
-
/**
|
|
171
|
-
* Queues the column hook of an element that stopped being an item for removal.
|
|
172
|
-
*
|
|
173
|
-
* The removal is queued rather than immediate because teardown reports every
|
|
174
|
-
* target as disconnected: doing it here would strip the whole grid just before
|
|
175
|
-
* a Turbo snapshot is taken. The coalescer's `cancel` drops the queue
|
|
176
|
-
* with the pass, so only a genuine target change reaches it.
|
|
177
|
-
*/
|
|
178
173
|
itemTargetDisconnected(item) {
|
|
179
174
|
this.#released.add(item);
|
|
180
175
|
this.#reconcile.schedule();
|
|
181
176
|
}
|
|
182
|
-
/** Observes size/content changes and performs the first layout pass. */
|
|
183
177
|
connect() {
|
|
184
178
|
this.#layout.observe(this.element);
|
|
185
179
|
this.#layout.observeViewport();
|
|
@@ -191,31 +185,13 @@ var MasonryController = class extends Controller {
|
|
|
191
185
|
this.#relayout();
|
|
192
186
|
this.#reconcile.activate();
|
|
193
187
|
}
|
|
194
|
-
/** Releases every observation so nothing fires after detach. */
|
|
195
188
|
disconnect() {
|
|
196
189
|
this.#reconcile.cancel();
|
|
197
|
-
this.#released.clear();
|
|
198
190
|
this.#layout.disconnect();
|
|
199
191
|
this.#mutationObserver?.disconnect();
|
|
200
192
|
this.#mutationObserver = null;
|
|
201
193
|
this.#lastColumns = 0;
|
|
202
194
|
}
|
|
203
|
-
/**
|
|
204
|
-
* Recomputes the column count and assigns every item to the shortest column.
|
|
205
|
-
* Runs automatically on connect, on resize, on item add/remove, when a declared
|
|
206
|
-
* number changes, and when a descendant resource loads (private — there is no
|
|
207
|
-
* public action; the observers, the target callbacks and the capture-phase
|
|
208
|
-
* `load` listener drive it). Items are walked in DOM order; each lands in the
|
|
209
|
-
* column with the least accumulated height, which keeps the packing balanced
|
|
210
|
-
* without reordering the DOM.
|
|
211
|
-
*
|
|
212
|
-
* Every box is measured before anything is written. Interleaving the two would
|
|
213
|
-
* make a consumer's `data-column` rule invalidate style once per item, and the
|
|
214
|
-
* next measurement then has to settle layout again — once per item instead of
|
|
215
|
-
* once per pass. The assignment is independent of the measurement because the
|
|
216
|
-
* columns are uniform in width, so the order of the two passes does not change
|
|
217
|
-
* the result.
|
|
218
|
-
*/
|
|
219
195
|
#relayout() {
|
|
220
196
|
const items = this.itemTargets;
|
|
221
197
|
const columns = this.#columnCount();
|
|
@@ -239,10 +215,7 @@ var MasonryController = class extends Controller {
|
|
|
239
215
|
if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
|
|
240
216
|
}
|
|
241
217
|
const assigned = String(shortest);
|
|
242
|
-
if (item
|
|
243
|
-
item.setAttribute("data-column", assigned);
|
|
244
|
-
changed = true;
|
|
245
|
-
}
|
|
218
|
+
if (setAttributeIfChanged(item, "data-column", assigned)) changed = true;
|
|
246
219
|
heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;
|
|
247
220
|
});
|
|
248
221
|
this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
|
|
@@ -251,20 +224,31 @@ var MasonryController = class extends Controller {
|
|
|
251
224
|
this.dispatch("layout", { detail: { columns } });
|
|
252
225
|
}
|
|
253
226
|
}
|
|
254
|
-
/**
|
|
255
|
-
* Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,
|
|
256
|
-
* never fewer than one. When the width is unmeasurable (detached, or a layout
|
|
257
|
-
* engine that reports `0`), it falls back to a single column so every item still
|
|
258
|
-
* gets a valid `data-column`.
|
|
259
|
-
*/
|
|
260
227
|
#columnCount() {
|
|
261
228
|
const width = this.element.getBoundingClientRect().width;
|
|
262
229
|
const denominator = this.#minColumnWidth + this.#gap;
|
|
263
230
|
if (width <= 0 || denominator <= 0) return 1;
|
|
264
231
|
return Math.max(1, Math.floor((width + this.#gap) / denominator));
|
|
265
232
|
}
|
|
233
|
+
get #safeMinColumnWidth() {
|
|
234
|
+
return this.#numbers.read(
|
|
235
|
+
this,
|
|
236
|
+
"minColumnWidth",
|
|
237
|
+
this.minColumnWidthValue,
|
|
238
|
+
_MasonryController.values.minColumnWidth.default,
|
|
239
|
+
_MasonryController.valueConstraints.minColumnWidth
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
get #safeGap() {
|
|
243
|
+
return this.#numbers.read(
|
|
244
|
+
this,
|
|
245
|
+
"gap",
|
|
246
|
+
this.gapValue,
|
|
247
|
+
_MasonryController.values.gap.default,
|
|
248
|
+
_MasonryController.valueConstraints.gap
|
|
249
|
+
);
|
|
250
|
+
}
|
|
266
251
|
};
|
|
267
252
|
|
|
268
253
|
export { MasonryController };
|
|
269
|
-
//# sourceMappingURL=masonry_controller.js.map
|
|
270
254
|
//# sourceMappingURL=masonry_controller.js.map
|