stimeo-ui 0.4.0 → 0.6.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 +184 -0
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +195 -40
- data/dist/controllers/breadcrumb_controller.js +5 -1
- data/dist/controllers/carousel_controller.js +90 -10
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/clipboard_controller.js +8 -3
- data/dist/controllers/collapsible_controller.js +4 -1
- data/dist/controllers/color_picker_controller.js +41 -11
- data/dist/controllers/context_menu_controller.js +2 -2
- data/dist/controllers/countdown_controller.js +5 -1
- data/dist/controllers/date_range_picker_controller.js +162 -31
- data/dist/controllers/direct_upload_controller.js +3 -3
- data/dist/controllers/empty_state_controller.js +107 -16
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/flash_controller.js +161 -21
- data/dist/controllers/form_validation_controller.js +8 -2
- data/dist/controllers/frame_loading_controller.js +94 -22
- data/dist/controllers/highlight_controller.js +38 -1
- data/dist/controllers/idle_controller.js +39 -6
- data/dist/controllers/local_time_controller.js +2 -0
- data/dist/controllers/masonry_controller.js +1 -1
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/meter_controller.js +3 -1
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/network_status_controller.js +1 -3
- data/dist/controllers/number_input_controller.js +455 -64
- data/dist/controllers/overflow_menu_controller.js +5 -1
- data/dist/controllers/pagination_controller.js +38 -1
- data/dist/controllers/password_strength_controller.js +21 -3
- data/dist/controllers/persist_controller.js +24 -5
- data/dist/controllers/pointer_drag_controller.js +10 -0
- data/dist/controllers/portal_controller.js +10 -0
- data/dist/controllers/progress_controller.js +7 -3
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/range_slider_controller.js +385 -94
- data/dist/controllers/rating_controller.js +274 -89
- data/dist/controllers/relative_time_controller.js +2 -0
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +155 -23
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/separator_controller.js +13 -17
- data/dist/controllers/skeleton_controller.js +71 -3
- data/dist/controllers/slider_controller.js +325 -48
- data/dist/controllers/spinner_controller.js +18 -3
- data/dist/controllers/step_indicator_controller.js +3 -1
- data/dist/controllers/stepper_controller.js +2 -0
- data/dist/controllers/switch_controller.js +162 -18
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/textarea_autosize_controller.js +1 -1
- data/dist/controllers/time_picker_controller.js +296 -104
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +24 -4
- data/dist/index.js +3811 -1048
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -18,6 +18,35 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
18
18
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// src/utils/before_cache_reset.ts
|
|
22
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
23
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
24
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
25
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
26
|
+
static #onBeforeCache = () => {
|
|
27
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
28
|
+
};
|
|
29
|
+
#rewind;
|
|
30
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
31
|
+
constructor(rewind) {
|
|
32
|
+
this.#rewind = rewind;
|
|
33
|
+
}
|
|
34
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
35
|
+
activate() {
|
|
36
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
37
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
38
|
+
if (first) {
|
|
39
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
43
|
+
deactivate() {
|
|
44
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
45
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
46
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
21
50
|
// src/utils/dates.ts
|
|
22
51
|
function toISODateString(date) {
|
|
23
52
|
const year = date.getFullYear();
|
|
@@ -138,16 +167,34 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
138
167
|
}
|
|
139
168
|
};
|
|
140
169
|
|
|
170
|
+
// src/utils/string_list.ts
|
|
171
|
+
function parseStringList(raw, fallback = []) {
|
|
172
|
+
const text = raw.trim();
|
|
173
|
+
if (text.length === 0) return [...fallback];
|
|
174
|
+
let parsed;
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(text);
|
|
177
|
+
} catch {
|
|
178
|
+
return [...fallback];
|
|
179
|
+
}
|
|
180
|
+
if (!Array.isArray(parsed)) return [...fallback];
|
|
181
|
+
return parsed.filter((entry) => typeof entry === "string");
|
|
182
|
+
}
|
|
183
|
+
|
|
141
184
|
// src/controllers/date_range_picker_controller.ts
|
|
142
185
|
var GRID_SIZE = 42;
|
|
143
186
|
var DateRangePickerController = class extends Controller {
|
|
144
187
|
static targets = ["grid", "monthLabel", "cell", "status", "startField", "endField"];
|
|
145
188
|
static values = {
|
|
146
189
|
min: { type: String, default: "" },
|
|
147
|
-
max: { type: String, default: "" }
|
|
190
|
+
max: { type: String, default: "" },
|
|
191
|
+
// A JSON list read through `parseStringList` rather than Stimulus's `Array`
|
|
192
|
+
// type: that reader throws out of the value observer before any callback
|
|
193
|
+
// runs, so one malformed attribute would stop the picker from connecting.
|
|
194
|
+
disabledDates: { type: String, default: "" }
|
|
148
195
|
};
|
|
149
196
|
static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
|
|
150
|
-
static events = ["change"];
|
|
197
|
+
static events = ["change", "monthchange"];
|
|
151
198
|
/** The month currently rendered, as `YYYY-MM`. */
|
|
152
199
|
#viewMonth = "";
|
|
153
200
|
/** The confirmed range endpoints (ISO), or "" when unset. */
|
|
@@ -161,7 +208,12 @@ var DateRangePickerController = class extends Controller {
|
|
|
161
208
|
#focusedDate = /* @__PURE__ */ new Date();
|
|
162
209
|
/** Deferred focus after an async month transition (cancelled on teardown). */
|
|
163
210
|
#focusTimer = new SafeTimeout();
|
|
164
|
-
/**
|
|
211
|
+
/** The declared unavailable dates, indexed for the per-cell paint lookup. */
|
|
212
|
+
#disabledDates = /* @__PURE__ */ new Set();
|
|
213
|
+
/** Rewinds an unfinished selection before Turbo snapshots the page. */
|
|
214
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
215
|
+
/** Last painted month, or `null` until the initial paint has settled. */
|
|
216
|
+
#announcedMonth = null;
|
|
165
217
|
/**
|
|
166
218
|
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
167
219
|
* pass Stimulus delivers before `connect()`.
|
|
@@ -169,10 +221,17 @@ var DateRangePickerController = class extends Controller {
|
|
|
169
221
|
#repaint = new MicrotaskCoalescer(() => {
|
|
170
222
|
this.#render();
|
|
171
223
|
});
|
|
224
|
+
/** Seeds and normalizes the range from optional hidden fields, then paints the grid. */
|
|
172
225
|
connect() {
|
|
173
226
|
this.#repaint.activate();
|
|
174
|
-
this.#
|
|
175
|
-
|
|
227
|
+
this.#beforeCache.activate();
|
|
228
|
+
const authoredStart = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
|
|
229
|
+
const authoredEnd = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
|
|
230
|
+
[this.#startDate, this.#endDate] = orderRange(authoredStart, authoredEnd);
|
|
231
|
+
this.#pendingStart = "";
|
|
232
|
+
this.#previewDate = "";
|
|
233
|
+
this.#announcedMonth = null;
|
|
234
|
+
this.#commitFields();
|
|
176
235
|
const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
|
|
177
236
|
this.#focusedDate = anchor;
|
|
178
237
|
this.#viewMonth = toISOMonthString(anchor);
|
|
@@ -182,6 +241,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
182
241
|
/** Cancels any pending deferred focus so it never fires on a detached element. */
|
|
183
242
|
disconnect() {
|
|
184
243
|
this.#repaint.cancel();
|
|
244
|
+
this.#beforeCache.deactivate();
|
|
185
245
|
this.#focusTimer.clearAll();
|
|
186
246
|
}
|
|
187
247
|
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
@@ -192,6 +252,11 @@ var DateRangePickerController = class extends Controller {
|
|
|
192
252
|
maxValueChanged() {
|
|
193
253
|
this.#repaint.schedule();
|
|
194
254
|
}
|
|
255
|
+
/** Re-indexes the unavailable dates and repaints when the declared set changes. */
|
|
256
|
+
disabledDatesValueChanged() {
|
|
257
|
+
this.#disabledDates = new Set(parseStringList(this.disabledDatesValue));
|
|
258
|
+
this.#repaint.schedule();
|
|
259
|
+
}
|
|
195
260
|
/** Navigates to the previous month. */
|
|
196
261
|
prev(event) {
|
|
197
262
|
event?.preventDefault();
|
|
@@ -207,7 +272,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
207
272
|
const cell = this.#cellFrom(event.target);
|
|
208
273
|
if (!cell) return;
|
|
209
274
|
const date = cell.getAttribute("data-date");
|
|
210
|
-
if (!date ||
|
|
275
|
+
if (!date || !this.#isSelectable(date)) return;
|
|
211
276
|
this.#choose(date);
|
|
212
277
|
}
|
|
213
278
|
/** Previews the range up to a hovered/focused cell while selecting. */
|
|
@@ -216,28 +281,27 @@ var DateRangePickerController = class extends Controller {
|
|
|
216
281
|
if (!cell) return;
|
|
217
282
|
const date = cell.getAttribute("data-date");
|
|
218
283
|
if (!date) return;
|
|
284
|
+
let shouldRender = false;
|
|
219
285
|
if (event.type.startsWith("focus")) {
|
|
220
286
|
const parsed = parseISODateString(date);
|
|
221
287
|
if (parsed) this.#focusedDate = parsed;
|
|
222
|
-
|
|
223
|
-
this.#render();
|
|
224
|
-
}
|
|
288
|
+
shouldRender = cell.getAttribute("tabindex") !== "0";
|
|
225
289
|
}
|
|
226
|
-
if (this.#pendingStart) {
|
|
290
|
+
if (this.#pendingStart && this.#isSelectable(date) && this.#previewDate !== date) {
|
|
227
291
|
this.#previewDate = date;
|
|
228
|
-
|
|
292
|
+
shouldRender = true;
|
|
229
293
|
}
|
|
294
|
+
if (shouldRender) this.#render();
|
|
230
295
|
}
|
|
231
296
|
/** Applies a named preset (`today` / `last7` / `last30` / `thisMonth`). */
|
|
232
297
|
applyPreset(event) {
|
|
233
298
|
const button = event.target?.closest("[data-range]");
|
|
234
|
-
const
|
|
235
|
-
if (!name) return;
|
|
236
|
-
const range = computePreset(name);
|
|
299
|
+
const range = computePreset(button?.getAttribute("data-range") ?? "");
|
|
237
300
|
if (!range) return;
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
301
|
+
const intersection = this.#intersectRange(range);
|
|
302
|
+
if (!intersection) return;
|
|
303
|
+
const { start, end } = intersection;
|
|
304
|
+
if (!this.#isSelectable(start) || !this.#isSelectable(end)) return;
|
|
241
305
|
this.#startDate = start;
|
|
242
306
|
this.#endDate = end;
|
|
243
307
|
this.#pendingStart = "";
|
|
@@ -255,12 +319,12 @@ var DateRangePickerController = class extends Controller {
|
|
|
255
319
|
if (isReservedArrowChord(event)) return;
|
|
256
320
|
const cell = this.#cellFrom(event.target);
|
|
257
321
|
if (!cell) return;
|
|
258
|
-
const dateStr = cell.getAttribute("data-date");
|
|
259
|
-
const date =
|
|
322
|
+
const dateStr = cell.getAttribute("data-date") ?? "";
|
|
323
|
+
const date = parseISODateString(dateStr);
|
|
260
324
|
if (!date) return;
|
|
261
325
|
if (event.key === "Enter" || event.key === " ") {
|
|
262
326
|
event.preventDefault();
|
|
263
|
-
if (dateStr
|
|
327
|
+
if (this.#isSelectable(dateStr)) this.#choose(dateStr);
|
|
264
328
|
return;
|
|
265
329
|
}
|
|
266
330
|
if (event.key === "Escape") {
|
|
@@ -293,10 +357,10 @@ var DateRangePickerController = class extends Controller {
|
|
|
293
357
|
next = addDays(date, 6 - date.getDay());
|
|
294
358
|
break;
|
|
295
359
|
case "PageUp":
|
|
296
|
-
next = shiftMonthClamped(date, -1);
|
|
360
|
+
next = event.shiftKey ? shiftYearClamped(date, -1) : shiftMonthClamped(date, -1);
|
|
297
361
|
break;
|
|
298
362
|
case "PageDown":
|
|
299
|
-
next = shiftMonthClamped(date, 1);
|
|
363
|
+
next = event.shiftKey ? shiftYearClamped(date, 1) : shiftMonthClamped(date, 1);
|
|
300
364
|
break;
|
|
301
365
|
default:
|
|
302
366
|
return;
|
|
@@ -327,12 +391,14 @@ var DateRangePickerController = class extends Controller {
|
|
|
327
391
|
/** Moves roving focus to `date`, transitioning the month when needed. */
|
|
328
392
|
#moveFocusTo(date) {
|
|
329
393
|
this.#focusedDate = date;
|
|
330
|
-
|
|
331
|
-
this.#
|
|
394
|
+
const iso = toISODateString(date);
|
|
395
|
+
if (this.#pendingStart && this.#isSelectable(iso)) this.#previewDate = iso;
|
|
396
|
+
this.#transitionTo(toISOMonthString(date), iso);
|
|
332
397
|
}
|
|
333
398
|
/** Renders `month`, then focuses the cell for `dateStr` (deferred if async). */
|
|
334
399
|
#transitionTo(month, dateStr) {
|
|
335
400
|
const isTransition = month !== this.#viewMonth;
|
|
401
|
+
this.#focusTimer.clearAll();
|
|
336
402
|
this.#viewMonth = month;
|
|
337
403
|
this.#render();
|
|
338
404
|
const focusCell = () => {
|
|
@@ -355,22 +421,28 @@ var DateRangePickerController = class extends Controller {
|
|
|
355
421
|
this.#focusedDate = target;
|
|
356
422
|
this.#render();
|
|
357
423
|
}
|
|
358
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* Builds the six-week grid and binds range/roving/disabled state per cell.
|
|
426
|
+
*
|
|
427
|
+
* @stimeoRenderRoot
|
|
428
|
+
*/
|
|
359
429
|
#render() {
|
|
360
430
|
const info = parseISOMonthString(this.#viewMonth);
|
|
361
431
|
if (!info) return;
|
|
362
432
|
const { year, month } = info;
|
|
433
|
+
if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
|
|
363
434
|
if (this.hasMonthLabelTarget) {
|
|
364
435
|
const lang = document.documentElement.lang || "en";
|
|
365
|
-
const formatter =
|
|
436
|
+
const formatter = monthFormatter(lang);
|
|
366
437
|
this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
|
|
367
438
|
}
|
|
368
439
|
const [rangeStart, rangeEnd] = this.#visualRange();
|
|
369
440
|
const days = gridDays(year, month);
|
|
370
441
|
const focusedStr = toISODateString(this.#focusedDate);
|
|
371
442
|
const todayStr = toISODateString(/* @__PURE__ */ new Date());
|
|
443
|
+
const cells = this.cellTargets;
|
|
372
444
|
for (let i = 0; i < GRID_SIZE; i++) {
|
|
373
|
-
const el =
|
|
445
|
+
const el = cells[i];
|
|
374
446
|
const date = days[i];
|
|
375
447
|
if (!el || !date) continue;
|
|
376
448
|
const iso = toISODateString(date);
|
|
@@ -379,8 +451,8 @@ var DateRangePickerController = class extends Controller {
|
|
|
379
451
|
el.setAttribute("data-outside", String(date.getMonth() !== month - 1));
|
|
380
452
|
el.setAttribute("data-today", String(iso === todayStr));
|
|
381
453
|
el.setAttribute("tabindex", iso === focusedStr ? "0" : "-1");
|
|
382
|
-
if (this.#
|
|
383
|
-
else el.
|
|
454
|
+
if (this.#isSelectable(iso)) el.removeAttribute("aria-disabled");
|
|
455
|
+
else el.setAttribute("aria-disabled", "true");
|
|
384
456
|
const isStart = !!rangeStart && iso === rangeStart;
|
|
385
457
|
const isEnd = !!rangeEnd && iso === rangeEnd && rangeEnd !== rangeStart;
|
|
386
458
|
const inside = !!rangeStart && !!rangeEnd && iso > rangeStart && iso < rangeEnd;
|
|
@@ -394,6 +466,16 @@ var DateRangePickerController = class extends Controller {
|
|
|
394
466
|
)
|
|
395
467
|
);
|
|
396
468
|
}
|
|
469
|
+
for (const extra of cells.slice(GRID_SIZE)) extra.setAttribute("tabindex", "-1");
|
|
470
|
+
if (!cells.some((cell) => cell.getAttribute("tabindex") === "0")) {
|
|
471
|
+
const fallback = cells.find((cell) => cell.getAttribute("data-outside") === "false") ?? cells[0];
|
|
472
|
+
fallback?.setAttribute("tabindex", "0");
|
|
473
|
+
}
|
|
474
|
+
const previous = this.#announcedMonth;
|
|
475
|
+
this.#announcedMonth = this.#viewMonth;
|
|
476
|
+
if (previous !== null && previous !== this.#viewMonth) {
|
|
477
|
+
this.dispatch("monthchange", { detail: { month: this.#viewMonth } });
|
|
478
|
+
}
|
|
397
479
|
}
|
|
398
480
|
/** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
|
|
399
481
|
#visualRange() {
|
|
@@ -414,15 +496,25 @@ var DateRangePickerController = class extends Controller {
|
|
|
414
496
|
this.statusTarget.textContent = `${this.#startDate} \u2013 ${this.#endDate}`;
|
|
415
497
|
}
|
|
416
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* True when `iso` may be chosen as a range endpoint.
|
|
501
|
+
*
|
|
502
|
+
* The single availability question in the controller: the grid paint, the
|
|
503
|
+
* preview, and both commit paths ask it, so a cell's `aria-disabled` and what
|
|
504
|
+
* a click on that cell does are the same decision rather than two that have to
|
|
505
|
+
* be kept in step.
|
|
506
|
+
*/
|
|
507
|
+
#isSelectable(iso) {
|
|
508
|
+
return !this.#outOfBounds(iso) && !this.#disabledDates.has(iso);
|
|
509
|
+
}
|
|
417
510
|
/** True when `iso` falls outside the `[min, max]` bounds. */
|
|
418
511
|
#outOfBounds(iso) {
|
|
419
512
|
if (this.minValue && iso < this.minValue) return true;
|
|
420
513
|
if (this.maxValue && iso > this.maxValue) return true;
|
|
421
514
|
return false;
|
|
422
515
|
}
|
|
423
|
-
/** Clamps
|
|
516
|
+
/** Clamps a generated ISO date string into `[min, max]`. */
|
|
424
517
|
#clampISO(iso) {
|
|
425
|
-
if (!iso) return "";
|
|
426
518
|
if (this.minValue && iso < this.minValue) return this.minValue;
|
|
427
519
|
if (this.maxValue && iso > this.maxValue) return this.maxValue;
|
|
428
520
|
return iso;
|
|
@@ -432,6 +524,27 @@ var DateRangePickerController = class extends Controller {
|
|
|
432
524
|
const clamped = this.#clampISO(toISODateString(date));
|
|
433
525
|
return parseISODateString(clamped);
|
|
434
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* Intersects a preset with `[min, max]`, rejecting a disjoint interval.
|
|
529
|
+
*
|
|
530
|
+
* Only the bounds narrow a preset here: `disabledDates` excludes single days,
|
|
531
|
+
* not sub-intervals, so it cannot shrink one to a still-contiguous range. A
|
|
532
|
+
* day it excludes is still refused as an endpoint — the caller checks that —
|
|
533
|
+
* but a preset that merely spans one keeps its span.
|
|
534
|
+
*/
|
|
535
|
+
#intersectRange(range) {
|
|
536
|
+
const start = this.minValue && range.start < this.minValue ? this.minValue : range.start;
|
|
537
|
+
const end = this.maxValue && range.end > this.maxValue ? this.maxValue : range.end;
|
|
538
|
+
return start <= end ? { start, end } : null;
|
|
539
|
+
}
|
|
540
|
+
/** Removes provisional range state before Turbo freezes a cached snapshot. */
|
|
541
|
+
#rewindForCache() {
|
|
542
|
+
this.#focusTimer.clearAll();
|
|
543
|
+
if (!this.#pendingStart && !this.#previewDate) return;
|
|
544
|
+
this.#pendingStart = "";
|
|
545
|
+
this.#previewDate = "";
|
|
546
|
+
this.#render();
|
|
547
|
+
}
|
|
435
548
|
/** Resolves the cell element from an event target, or null. */
|
|
436
549
|
#cellFrom(target) {
|
|
437
550
|
return target?.closest(
|
|
@@ -450,6 +563,12 @@ function shiftMonthClamped(date, delta) {
|
|
|
450
563
|
target.setDate(Math.min(date.getDate(), lastDay));
|
|
451
564
|
return target;
|
|
452
565
|
}
|
|
566
|
+
function shiftYearClamped(date, delta) {
|
|
567
|
+
const target = new Date(date.getFullYear() + delta, date.getMonth(), 1);
|
|
568
|
+
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
|
569
|
+
target.setDate(Math.min(date.getDate(), lastDay));
|
|
570
|
+
return target;
|
|
571
|
+
}
|
|
453
572
|
function gridDays(year, month) {
|
|
454
573
|
const first = new Date(year, month - 1, 1);
|
|
455
574
|
const start = new Date(first);
|
|
@@ -466,6 +585,18 @@ function normalizeISO(value) {
|
|
|
466
585
|
const date = parseISODateString(value.trim());
|
|
467
586
|
return date ? toISODateString(date) : "";
|
|
468
587
|
}
|
|
588
|
+
function orderRange(start, end) {
|
|
589
|
+
return start && end && end < start ? [end, start] : [start, end];
|
|
590
|
+
}
|
|
591
|
+
function monthFormatter(locale) {
|
|
592
|
+
const options = { month: "long", year: "numeric" };
|
|
593
|
+
try {
|
|
594
|
+
return new Intl.DateTimeFormat(locale, options);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
if (!(error instanceof RangeError)) throw error;
|
|
597
|
+
return new Intl.DateTimeFormat("en", options);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
469
600
|
function computePreset(name) {
|
|
470
601
|
const today = /* @__PURE__ */ new Date();
|
|
471
602
|
const todayStr = toISODateString(today);
|
|
@@ -110,7 +110,7 @@ var DirectUploadController = class extends Controller {
|
|
|
110
110
|
const clamped = Math.max(0, Math.min(100, percent));
|
|
111
111
|
row.setAttribute("aria-valuenow", String(clamped));
|
|
112
112
|
row.setAttribute("aria-valuetext", `${clamped}%`);
|
|
113
|
-
row.style.setProperty("--stimeo
|
|
113
|
+
row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
|
|
114
114
|
this.#setField(row, "percent", `${clamped}%`);
|
|
115
115
|
this.#syncAggregate();
|
|
116
116
|
this.dispatch("progress", { detail: { id, percent: clamped } });
|
|
@@ -148,7 +148,7 @@ var DirectUploadController = class extends Controller {
|
|
|
148
148
|
}
|
|
149
149
|
clone.setAttribute("aria-valuenow", "0");
|
|
150
150
|
clone.setAttribute("data-upload-state", "uploading");
|
|
151
|
-
clone.style.setProperty("--stimeo
|
|
151
|
+
clone.style.setProperty("--stimeo--upload-progress", "0%");
|
|
152
152
|
this.listTarget.appendChild(clone);
|
|
153
153
|
this.#rows.set(key, clone);
|
|
154
154
|
return clone;
|
|
@@ -172,7 +172,7 @@ var DirectUploadController = class extends Controller {
|
|
|
172
172
|
}
|
|
173
173
|
const overall = Math.round(total / this.#rows.size);
|
|
174
174
|
this.element.setAttribute("data-upload-progress", String(overall));
|
|
175
|
-
this.element.style.setProperty("--stimeo
|
|
175
|
+
this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
|
|
176
176
|
}
|
|
177
177
|
/** Writes a consumer label (with `%{name}` substituted) to the status region. */
|
|
178
178
|
#announce(label, row) {
|
|
@@ -29,23 +29,120 @@ var EmptyStateController = class extends Controller {
|
|
|
29
29
|
};
|
|
30
30
|
static events = ["change"];
|
|
31
31
|
#observer = null;
|
|
32
|
+
/** Whether the controller is between `connect()` and `disconnect()`. */
|
|
33
|
+
#connected = false;
|
|
32
34
|
/** Last applied empty state; `null` until the first sync so connect emits nothing. */
|
|
33
35
|
#empty = null;
|
|
34
36
|
connect() {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
this.#observer.observe(this.listTarget, { childList: true });
|
|
39
|
-
}
|
|
40
|
-
this.#apply();
|
|
37
|
+
this.#connected = true;
|
|
38
|
+
this.#syncObservation();
|
|
39
|
+
this.#update();
|
|
41
40
|
}
|
|
42
41
|
disconnect() {
|
|
42
|
+
this.#connected = false;
|
|
43
|
+
this.#stopObserving();
|
|
44
|
+
}
|
|
45
|
+
/** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */
|
|
46
|
+
listTargetConnected() {
|
|
47
|
+
this.#resync();
|
|
48
|
+
}
|
|
49
|
+
/** Releases the observation when the `list` element leaves the target set. */
|
|
50
|
+
listTargetDisconnected() {
|
|
51
|
+
this.#resync();
|
|
52
|
+
}
|
|
53
|
+
/** Syncs an `empty` element that arrives — or is replaced — at runtime. */
|
|
54
|
+
emptyTargetConnected() {
|
|
55
|
+
this.#resync();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Syncs the `empty` element that remains when one leaves the target set. A
|
|
59
|
+
* single-target getter resolves to the first `empty` element in document order,
|
|
60
|
+
* so a swap that inserts the replacement *before* removing the original (Turbo
|
|
61
|
+
* Stream `after` / `before` / `append` followed by `remove`) leaves the
|
|
62
|
+
* replacement untouched until the original goes — this callback is that moment.
|
|
63
|
+
*/
|
|
64
|
+
emptyTargetDisconnected() {
|
|
65
|
+
this.#resync();
|
|
66
|
+
}
|
|
67
|
+
/** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */
|
|
68
|
+
itemSelectorValueChanged() {
|
|
69
|
+
this.#resync();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Re-points the observation and re-renders after a target or selector change.
|
|
73
|
+
* The `#connected` guard is load-bearing: Stimulus runs value and target
|
|
74
|
+
* callbacks for the initial markup *before* `connect()` and runs target
|
|
75
|
+
* callbacks during teardown *after* `disconnect()`, and re-observing there
|
|
76
|
+
* would outlive the controller.
|
|
77
|
+
*/
|
|
78
|
+
#resync() {
|
|
79
|
+
if (!this.#connected) return;
|
|
80
|
+
this.#syncObservation();
|
|
81
|
+
this.#update();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Points the mutation observation at the current `list` target — re-resolved on
|
|
85
|
+
* every sync rather than captured at connect, so an element swapped in at
|
|
86
|
+
* runtime is observed instead of the detached original.
|
|
87
|
+
*
|
|
88
|
+
* The observation covers exactly what the count predicate reads. With no
|
|
89
|
+
* `itemSelector` the count is the child element count, which only `childList`
|
|
90
|
+
* can change. With one, the predicate reads the children themselves, so
|
|
91
|
+
* attribute and descendant mutations are watched too — and the controller's own
|
|
92
|
+
* writes are filtered back out, or toggling `hidden` would re-enter the render.
|
|
93
|
+
*/
|
|
94
|
+
#syncObservation() {
|
|
95
|
+
this.#stopObserving();
|
|
96
|
+
if (!this.hasListTarget || typeof MutationObserver === "undefined") return;
|
|
97
|
+
const watchesItems = this.itemSelectorValue.length > 0;
|
|
98
|
+
this.#observer = new MutationObserver((records) => {
|
|
99
|
+
if (records.some((record) => this.#affectsCount(record))) this.#update();
|
|
100
|
+
});
|
|
101
|
+
this.#observer.observe(this.listTarget, {
|
|
102
|
+
childList: true,
|
|
103
|
+
subtree: watchesItems,
|
|
104
|
+
attributes: watchesItems
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
#stopObserving() {
|
|
43
108
|
this.#observer?.disconnect();
|
|
44
109
|
this.#observer = null;
|
|
45
110
|
}
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Whether a mutation can change the item count. An attribute written on a
|
|
113
|
+
* target this controller owns is its own echo — `hidden` on `list` / `empty`,
|
|
114
|
+
* and the hooks on the controller element when the list *is* that element.
|
|
115
|
+
*/
|
|
116
|
+
#affectsCount(record) {
|
|
117
|
+
if (record.type !== "attributes") return true;
|
|
118
|
+
const own = record.target === this.listTarget || this.hasEmptyTarget && record.target === this.emptyTarget;
|
|
119
|
+
return !own;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Renders the count, then reports a crossed boundary. The announcement copy is
|
|
123
|
+
* read here rather than while rendering: it is the wording of the report, not
|
|
124
|
+
* an input to what is displayed.
|
|
125
|
+
*/
|
|
126
|
+
#update() {
|
|
127
|
+
const count = this.#render();
|
|
128
|
+
if (count === null) return;
|
|
129
|
+
const empty = count === 0;
|
|
130
|
+
const crossed = this.#empty !== null && empty !== this.#empty;
|
|
131
|
+
this.#empty = empty;
|
|
132
|
+
if (!crossed) return;
|
|
133
|
+
this.dispatch("change", { detail: { count, empty } });
|
|
134
|
+
announce(
|
|
135
|
+
fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Syncs visibility and the state hooks to the current item count, and returns
|
|
140
|
+
* it. `null` when there is no `list` target to count.
|
|
141
|
+
*
|
|
142
|
+
* @stimeoRenderRoot
|
|
143
|
+
*/
|
|
144
|
+
#render() {
|
|
145
|
+
if (!this.hasListTarget) return null;
|
|
49
146
|
const count = this.#count();
|
|
50
147
|
const empty = count === 0;
|
|
51
148
|
this.element.setAttribute("data-count", String(count));
|
|
@@ -56,13 +153,7 @@ var EmptyStateController = class extends Controller {
|
|
|
56
153
|
}
|
|
57
154
|
this.listTarget.hidden = empty;
|
|
58
155
|
if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
|
|
59
|
-
|
|
60
|
-
this.dispatch("change", { detail: { count, empty } });
|
|
61
|
-
announce(
|
|
62
|
-
fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
this.#empty = empty;
|
|
156
|
+
return count;
|
|
66
157
|
}
|
|
67
158
|
/** Item count: element children matching `itemSelector`, or all element children. */
|
|
68
159
|
#count() {
|
|
@@ -12,18 +12,38 @@ var FileDropzoneController = class extends Controller {
|
|
|
12
12
|
static events = ["change", "reject"];
|
|
13
13
|
/** Selected files paired with their rendered item and any preview objectURL. */
|
|
14
14
|
#entries = [];
|
|
15
|
+
/** Prevents initial and teardown target callbacks from binding outside controller lifetime. */
|
|
16
|
+
#connected = false;
|
|
15
17
|
/** Wires file removal as a delegated listener on the list container. */
|
|
16
18
|
connect() {
|
|
17
|
-
|
|
19
|
+
this.#connected = true;
|
|
20
|
+
if (this.hasListTarget) this.#bindList(this.listTarget);
|
|
18
21
|
}
|
|
19
22
|
/** Revokes any outstanding preview URLs so none leaks across navigations. */
|
|
20
23
|
disconnect() {
|
|
21
|
-
|
|
24
|
+
this.#connected = false;
|
|
25
|
+
for (const list of this.listTargets) list.removeEventListener("click", this.#onItemClick);
|
|
22
26
|
for (const entry of this.#entries) {
|
|
23
27
|
if (entry.url) URL.revokeObjectURL(entry.url);
|
|
24
28
|
}
|
|
25
29
|
this.#entries.length = 0;
|
|
26
30
|
}
|
|
31
|
+
/** Rebinds removal and restores client-only previews when Turbo replaces the list target. */
|
|
32
|
+
listTargetConnected(list) {
|
|
33
|
+
if (!this.#connected) return;
|
|
34
|
+
this.#bindList(list);
|
|
35
|
+
}
|
|
36
|
+
/** Releases only the list target that actually disconnected. */
|
|
37
|
+
listTargetDisconnected(list) {
|
|
38
|
+
list.removeEventListener("click", this.#onItemClick);
|
|
39
|
+
}
|
|
40
|
+
/** Binds delegated removal once and moves live preview items into the current list. */
|
|
41
|
+
#bindList(list) {
|
|
42
|
+
list.addEventListener("click", this.#onItemClick);
|
|
43
|
+
for (const entry of this.#entries) {
|
|
44
|
+
if (!list.contains(entry.item)) list.appendChild(entry.item);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
27
47
|
/** Opens the native file dialog. Bound via `data-action` (trigger click). */
|
|
28
48
|
openDialog() {
|
|
29
49
|
this.inputTarget.click();
|
|
@@ -55,8 +75,11 @@ var FileDropzoneController = class extends Controller {
|
|
|
55
75
|
* an item is appended without waiting on Stimulus to wire a freshly created element.
|
|
56
76
|
*/
|
|
57
77
|
#onItemClick = (event) => {
|
|
78
|
+
const list = event.currentTarget;
|
|
58
79
|
const button = event.target.closest("button");
|
|
59
|
-
if (!button || !this.hasListTarget ||
|
|
80
|
+
if (!this.#connected || !list || !button || !this.hasListTarget || list !== this.listTarget || !list.contains(button)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
60
83
|
const index = this.#entries.findIndex((entry) => entry.item.contains(button));
|
|
61
84
|
if (index !== -1) this.#removeAt(index);
|
|
62
85
|
};
|