stimeo-ui 0.2.1 → 0.4.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 +160 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +92 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +226 -22
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +77 -3
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +102 -8
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +408 -57
- data/dist/controllers/pagination_controller.js +163 -32
- 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/progress_controller.js +116 -9
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +100 -9
- data/dist/controllers/rating_controller.js +69 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +49 -8
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +61 -10
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +4507 -907
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/range_slider_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowKey(key, element) {
|
|
12
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
13
|
+
if (!isRtl(element)) return key;
|
|
14
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
15
|
+
}
|
|
16
|
+
function isReservedArrowChord(event, allow = []) {
|
|
17
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
18
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/utils/microtask_coalescer.ts
|
|
22
|
+
var MicrotaskCoalescer = class {
|
|
23
|
+
#run;
|
|
24
|
+
#queued = false;
|
|
25
|
+
#active = false;
|
|
26
|
+
#generation = 0;
|
|
27
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
28
|
+
constructor(run) {
|
|
29
|
+
this.#run = run;
|
|
30
|
+
}
|
|
31
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
32
|
+
activate() {
|
|
33
|
+
this.#active = true;
|
|
34
|
+
}
|
|
35
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
36
|
+
cancel() {
|
|
37
|
+
this.#active = false;
|
|
38
|
+
this.#queued = false;
|
|
39
|
+
this.#generation += 1;
|
|
40
|
+
}
|
|
41
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
42
|
+
schedule() {
|
|
43
|
+
if (!this.#active || this.#queued) return;
|
|
44
|
+
this.#queued = true;
|
|
45
|
+
const generation = this.#generation;
|
|
46
|
+
queueMicrotask(() => {
|
|
47
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
48
|
+
this.#queued = false;
|
|
49
|
+
this.#run();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// src/utils/range.ts
|
|
55
|
+
function rangeFraction(value, min, max) {
|
|
56
|
+
const span = max - min;
|
|
57
|
+
if (!(span > 0)) return 0;
|
|
58
|
+
const clamped = Math.min(max, Math.max(min, value));
|
|
59
|
+
let fraction;
|
|
60
|
+
if (Number.isFinite(span)) {
|
|
61
|
+
fraction = (clamped - min) / span;
|
|
62
|
+
} else {
|
|
63
|
+
const scale = Math.max(Math.abs(min), Math.abs(max));
|
|
64
|
+
fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isFinite(fraction)) return 0;
|
|
67
|
+
return Math.min(1, Math.max(0, fraction));
|
|
68
|
+
}
|
|
69
|
+
|
|
3
70
|
// src/controllers/range_slider_controller.ts
|
|
4
71
|
var START_PROPERTY = "--stimeo-range-start";
|
|
5
72
|
var END_PROPERTY = "--stimeo-range-end";
|
|
@@ -10,25 +77,48 @@ var RangeSliderController = class extends Controller {
|
|
|
10
77
|
max: { type: Number, default: 100 },
|
|
11
78
|
step: { type: Number, default: 1 },
|
|
12
79
|
start: { type: Number, default: 0 },
|
|
13
|
-
end: { type: Number, default: 100 }
|
|
80
|
+
end: { type: Number, default: 100 },
|
|
81
|
+
logicalTrack: { type: Boolean, default: false }
|
|
14
82
|
};
|
|
15
83
|
static actions = ["onKeydown", "onPointerDown"];
|
|
16
84
|
static events = ["change"];
|
|
17
85
|
/** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
|
|
18
86
|
#dragAbort = null;
|
|
87
|
+
/** Whether the consumer declared a mirroring track and the direction mirrors it. */
|
|
88
|
+
get #mirrored() {
|
|
89
|
+
return this.logicalTrackValue && isRtl(this.element);
|
|
90
|
+
}
|
|
19
91
|
/** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
|
|
92
|
+
/**
|
|
93
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
94
|
+
* pass Stimulus delivers before `connect()`.
|
|
95
|
+
*/
|
|
96
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
97
|
+
this.#render(this.startValue, this.endValue);
|
|
98
|
+
});
|
|
20
99
|
connect() {
|
|
100
|
+
this.#repaint.activate();
|
|
21
101
|
const lo = Math.min(this.startValue, this.endValue);
|
|
22
102
|
const hi = Math.max(this.startValue, this.endValue);
|
|
23
103
|
this.#commit(lo, hi, false);
|
|
24
104
|
}
|
|
25
105
|
/** Cancels any active pointer drag so document listeners never leak. */
|
|
26
106
|
disconnect() {
|
|
107
|
+
this.#repaint.cancel();
|
|
27
108
|
this.#dragAbort?.abort();
|
|
28
109
|
this.#dragAbort = null;
|
|
29
110
|
}
|
|
111
|
+
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
112
|
+
minValueChanged() {
|
|
113
|
+
this.#repaint.schedule();
|
|
114
|
+
}
|
|
115
|
+
/** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
|
|
116
|
+
maxValueChanged() {
|
|
117
|
+
this.#repaint.schedule();
|
|
118
|
+
}
|
|
30
119
|
/** Keyboard stepping for whichever thumb is focused (the action's element). */
|
|
31
120
|
onKeydown(event) {
|
|
121
|
+
if (isReservedArrowChord(event)) return;
|
|
32
122
|
const thumb = event.currentTarget;
|
|
33
123
|
const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;
|
|
34
124
|
const current = isStart ? this.startValue : this.endValue;
|
|
@@ -36,7 +126,7 @@ var RangeSliderController = class extends Controller {
|
|
|
36
126
|
const upper = isStart ? this.endValue : this.maxValue;
|
|
37
127
|
const big = this.stepValue * 10;
|
|
38
128
|
let next = null;
|
|
39
|
-
switch (event.key) {
|
|
129
|
+
switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
|
|
40
130
|
case "ArrowRight":
|
|
41
131
|
case "ArrowUp":
|
|
42
132
|
next = current + this.stepValue;
|
|
@@ -66,7 +156,8 @@ var RangeSliderController = class extends Controller {
|
|
|
66
156
|
/** Begins a pointer drag on the track, moving the thumb nearest the press. */
|
|
67
157
|
onPointerDown(event) {
|
|
68
158
|
if (!this.hasTrackTarget) return;
|
|
69
|
-
const
|
|
159
|
+
const mirrored = this.#mirrored;
|
|
160
|
+
const value = this.#valueFromClientX(event.clientX, mirrored);
|
|
70
161
|
if (value === null) return;
|
|
71
162
|
event.preventDefault();
|
|
72
163
|
const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);
|
|
@@ -80,7 +171,7 @@ var RangeSliderController = class extends Controller {
|
|
|
80
171
|
const abort = new AbortController();
|
|
81
172
|
this.#dragAbort = abort;
|
|
82
173
|
const onMove = (move) => {
|
|
83
|
-
const moved = this.#valueFromClientX(move.clientX);
|
|
174
|
+
const moved = this.#valueFromClientX(move.clientX, mirrored);
|
|
84
175
|
if (moved !== null) this.#moveThumb(useStart, moved);
|
|
85
176
|
};
|
|
86
177
|
const onUp = () => {
|
|
@@ -92,10 +183,11 @@ var RangeSliderController = class extends Controller {
|
|
|
92
183
|
document.addEventListener("pointercancel", onUp, { signal: abort.signal });
|
|
93
184
|
}
|
|
94
185
|
/** Maps a pointer X coordinate to a raw value using the track geometry. */
|
|
95
|
-
#valueFromClientX(clientX) {
|
|
186
|
+
#valueFromClientX(clientX, mirrored) {
|
|
96
187
|
const rect = this.trackTarget.getBoundingClientRect();
|
|
97
188
|
if (rect.width === 0) return null;
|
|
98
|
-
const
|
|
189
|
+
const offset = (clientX - rect.left) / rect.width;
|
|
190
|
+
const fraction = mirrored ? 1 - offset : offset;
|
|
99
191
|
return this.minValue + fraction * (this.maxValue - this.minValue);
|
|
100
192
|
}
|
|
101
193
|
/** Moves one thumb to a new raw value, keeping the pair ordered. */
|
|
@@ -139,14 +231,13 @@ var RangeSliderController = class extends Controller {
|
|
|
139
231
|
this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
|
|
140
232
|
this.endThumbTarget.setAttribute("aria-valuenow", String(end));
|
|
141
233
|
}
|
|
142
|
-
const span = this.maxValue - this.minValue;
|
|
143
234
|
this.element.style.setProperty(
|
|
144
235
|
START_PROPERTY,
|
|
145
|
-
String(
|
|
236
|
+
String(rangeFraction(start, this.minValue, this.maxValue))
|
|
146
237
|
);
|
|
147
238
|
this.element.style.setProperty(
|
|
148
239
|
END_PROPERTY,
|
|
149
|
-
String(
|
|
240
|
+
String(rangeFraction(end, this.minValue, this.maxValue))
|
|
150
241
|
);
|
|
151
242
|
}
|
|
152
243
|
/** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
|
|
@@ -2,6 +2,50 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/rating_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function isReservedArrowChord(event, allow = []) {
|
|
12
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
13
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/utils/microtask_coalescer.ts
|
|
17
|
+
var MicrotaskCoalescer = class {
|
|
18
|
+
#run;
|
|
19
|
+
#queued = false;
|
|
20
|
+
#active = false;
|
|
21
|
+
#generation = 0;
|
|
22
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
23
|
+
constructor(run) {
|
|
24
|
+
this.#run = run;
|
|
25
|
+
}
|
|
26
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
27
|
+
activate() {
|
|
28
|
+
this.#active = true;
|
|
29
|
+
}
|
|
30
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
31
|
+
cancel() {
|
|
32
|
+
this.#active = false;
|
|
33
|
+
this.#queued = false;
|
|
34
|
+
this.#generation += 1;
|
|
35
|
+
}
|
|
36
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
37
|
+
schedule() {
|
|
38
|
+
if (!this.#active || this.#queued) return;
|
|
39
|
+
this.#queued = true;
|
|
40
|
+
const generation = this.#generation;
|
|
41
|
+
queueMicrotask(() => {
|
|
42
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
43
|
+
this.#queued = false;
|
|
44
|
+
this.#run();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
5
49
|
// src/utils/roving_tabindex.ts
|
|
6
50
|
var RovingTabindex = class {
|
|
7
51
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -48,13 +92,33 @@ var RatingController = class extends Controller {
|
|
|
48
92
|
static events = ["change"];
|
|
49
93
|
#roving = new RovingTabindex(() => this.symbolTargets);
|
|
50
94
|
/** Reflects the initial value, or switches to the non-interactive readonly view. */
|
|
95
|
+
/**
|
|
96
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
97
|
+
* pass Stimulus delivers before `connect()`.
|
|
98
|
+
*/
|
|
99
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
100
|
+
if (this.readonlyValue) {
|
|
101
|
+
this.#applyReadonly();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.#apply(this.#clamp(this.valueValue), { focus: false });
|
|
105
|
+
});
|
|
51
106
|
connect() {
|
|
107
|
+
this.#repaint.activate();
|
|
52
108
|
if (this.readonlyValue) {
|
|
53
109
|
this.#applyReadonly();
|
|
54
110
|
return;
|
|
55
111
|
}
|
|
56
112
|
this.#apply(this.#clamp(this.valueValue), { focus: false });
|
|
57
113
|
}
|
|
114
|
+
/** Closes the window in which a queued repaint may still run. */
|
|
115
|
+
disconnect() {
|
|
116
|
+
this.#repaint.cancel();
|
|
117
|
+
}
|
|
118
|
+
/** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
|
|
119
|
+
valueValueChanged() {
|
|
120
|
+
this.#repaint.schedule();
|
|
121
|
+
}
|
|
58
122
|
/** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
|
|
59
123
|
select(event) {
|
|
60
124
|
if (this.readonlyValue) return;
|
|
@@ -77,16 +141,19 @@ var RatingController = class extends Controller {
|
|
|
77
141
|
}
|
|
78
142
|
/** Arrow/Home/End/Space keyboard control, clamped (no wrap). */
|
|
79
143
|
onKeydown(event) {
|
|
144
|
+
if (event.defaultPrevented) return;
|
|
145
|
+
if (isReservedArrowChord(event)) return;
|
|
80
146
|
if (this.readonlyValue) return;
|
|
81
147
|
let next = null;
|
|
148
|
+
const rtl = isRtl(this.element);
|
|
82
149
|
switch (event.key) {
|
|
83
150
|
case "ArrowRight":
|
|
84
151
|
case "ArrowUp":
|
|
85
|
-
next = this.valueValue + 1;
|
|
152
|
+
next = this.valueValue + (event.key === "ArrowRight" && rtl ? -1 : 1);
|
|
86
153
|
break;
|
|
87
154
|
case "ArrowLeft":
|
|
88
155
|
case "ArrowDown":
|
|
89
|
-
next = this.valueValue - 1;
|
|
156
|
+
next = this.valueValue - (event.key === "ArrowLeft" && rtl ? -1 : 1);
|
|
90
157
|
break;
|
|
91
158
|
case "Home":
|
|
92
159
|
next = this.#minValue;
|
|
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/read_more_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/blur_deferral.ts
|
|
6
|
+
var BlurDeferral = class {
|
|
7
|
+
/** Elements currently holding an update back, mapped to their `blur` listener. */
|
|
8
|
+
#pending = /* @__PURE__ */ new Map();
|
|
9
|
+
/** Called after a pending element blurs and has been detached. */
|
|
10
|
+
#onRelease;
|
|
11
|
+
/** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
|
|
12
|
+
constructor(onRelease) {
|
|
13
|
+
this.#onRelease = onRelease;
|
|
14
|
+
}
|
|
15
|
+
/** Number of elements currently holding an update back. */
|
|
16
|
+
get size() {
|
|
17
|
+
return this.#pending.size;
|
|
18
|
+
}
|
|
19
|
+
/** Snapshot of the pending elements, safe to iterate while releasing them. */
|
|
20
|
+
get elements() {
|
|
21
|
+
return [...this.#pending.keys()];
|
|
22
|
+
}
|
|
23
|
+
/** Whether `element` is currently holding an update back. */
|
|
24
|
+
has(element) {
|
|
25
|
+
return this.#pending.has(element);
|
|
26
|
+
}
|
|
27
|
+
/** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
|
|
28
|
+
defer(element) {
|
|
29
|
+
if (this.#pending.has(element)) return;
|
|
30
|
+
const onBlur = () => {
|
|
31
|
+
this.#detach(element);
|
|
32
|
+
this.#onRelease(element);
|
|
33
|
+
};
|
|
34
|
+
this.#pending.set(element, onBlur);
|
|
35
|
+
element.addEventListener("blur", onBlur);
|
|
36
|
+
}
|
|
37
|
+
/** Defers `element` as the only pending entry, cancelling any others. */
|
|
38
|
+
deferOnly(element) {
|
|
39
|
+
for (const pending of this.elements) {
|
|
40
|
+
if (pending !== element) this.#detach(pending);
|
|
41
|
+
}
|
|
42
|
+
this.defer(element);
|
|
43
|
+
}
|
|
44
|
+
/** Cancels `element`'s deferral without completing it; no-ops when not pending. */
|
|
45
|
+
release(element) {
|
|
46
|
+
this.#detach(element);
|
|
47
|
+
}
|
|
48
|
+
/** Cancels every deferral without completing any of them. */
|
|
49
|
+
releaseAll() {
|
|
50
|
+
for (const element of this.elements) this.#detach(element);
|
|
51
|
+
}
|
|
52
|
+
/** Removes the `blur` listener for `element` and forgets it. */
|
|
53
|
+
#detach(element) {
|
|
54
|
+
const onBlur = this.#pending.get(element);
|
|
55
|
+
if (onBlur) element.removeEventListener("blur", onBlur);
|
|
56
|
+
this.#pending.delete(element);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
5
60
|
// src/utils/layout_observer.ts
|
|
6
61
|
var LayoutObserver = class {
|
|
7
62
|
#callback;
|
|
@@ -69,15 +124,14 @@ var ReadMoreController = class extends Controller {
|
|
|
69
124
|
#collapsed = true;
|
|
70
125
|
#observedContent = null;
|
|
71
126
|
#contentMutationObserver = null;
|
|
72
|
-
#deferredHideTrigger = null;
|
|
73
127
|
#update = () => {
|
|
74
128
|
if (this.#connected) this.#evaluateOverflow();
|
|
75
129
|
};
|
|
76
130
|
#layout = new LayoutObserver(this.#update);
|
|
77
|
-
|
|
78
|
-
|
|
131
|
+
/** Holds the trigger's hide back while it has focus; re-evaluates on blur. */
|
|
132
|
+
#deferredHide = new BlurDeferral(() => {
|
|
79
133
|
this.#update();
|
|
80
|
-
};
|
|
134
|
+
});
|
|
81
135
|
connect() {
|
|
82
136
|
this.#connected = true;
|
|
83
137
|
this.#collapsed = this.#initialCollapsed();
|
|
@@ -98,7 +152,7 @@ var ReadMoreController = class extends Controller {
|
|
|
98
152
|
this.#syncTargets();
|
|
99
153
|
}
|
|
100
154
|
triggerTargetDisconnected(trigger) {
|
|
101
|
-
|
|
155
|
+
this.#deferredHide.release(trigger);
|
|
102
156
|
this.#syncTargets();
|
|
103
157
|
}
|
|
104
158
|
/** Toggles between the collapsed (clamped) and expanded states. */
|
|
@@ -149,7 +203,7 @@ var ReadMoreController = class extends Controller {
|
|
|
149
203
|
}
|
|
150
204
|
}
|
|
151
205
|
#stopObservingContent() {
|
|
152
|
-
this.#
|
|
206
|
+
this.#deferredHide.releaseAll();
|
|
153
207
|
if (this.#observedContent) {
|
|
154
208
|
this.#layout.unobserve(this.#observedContent);
|
|
155
209
|
this.#observedContent.removeEventListener("load", this.#update, true);
|
|
@@ -159,32 +213,22 @@ var ReadMoreController = class extends Controller {
|
|
|
159
213
|
this.#contentMutationObserver = null;
|
|
160
214
|
this.#layout.unobserveViewport();
|
|
161
215
|
}
|
|
162
|
-
#deferHide(trigger) {
|
|
163
|
-
if (this.#deferredHideTrigger === trigger) return;
|
|
164
|
-
this.#clearDeferredHide();
|
|
165
|
-
this.#deferredHideTrigger = trigger;
|
|
166
|
-
trigger.addEventListener("blur", this.#onDeferredHideBlur);
|
|
167
|
-
}
|
|
168
|
-
#clearDeferredHide() {
|
|
169
|
-
this.#deferredHideTrigger?.removeEventListener("blur", this.#onDeferredHideBlur);
|
|
170
|
-
this.#deferredHideTrigger = null;
|
|
171
|
-
}
|
|
172
216
|
#evaluateOverflow() {
|
|
173
217
|
if (!this.hasTriggerTarget || !this.hasContentTarget) return;
|
|
174
218
|
const trigger = this.triggerTarget;
|
|
175
219
|
const content = this.contentTarget;
|
|
176
220
|
const useful = !this.#collapsed || content.scrollHeight > content.clientHeight;
|
|
177
221
|
if (useful) {
|
|
178
|
-
this.#
|
|
222
|
+
this.#deferredHide.releaseAll();
|
|
179
223
|
trigger.hidden = false;
|
|
180
224
|
return;
|
|
181
225
|
}
|
|
182
226
|
if (document.activeElement === trigger) {
|
|
183
227
|
trigger.hidden = false;
|
|
184
|
-
this.#
|
|
228
|
+
this.#deferredHide.deferOnly(trigger);
|
|
185
229
|
return;
|
|
186
230
|
}
|
|
187
|
-
this.#
|
|
231
|
+
this.#deferredHide.releaseAll();
|
|
188
232
|
trigger.hidden = true;
|
|
189
233
|
}
|
|
190
234
|
};
|
|
@@ -2,6 +2,68 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/relative_time_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/utils/microtask_coalescer.ts
|
|
35
|
+
var MicrotaskCoalescer = class {
|
|
36
|
+
#run;
|
|
37
|
+
#queued = false;
|
|
38
|
+
#active = false;
|
|
39
|
+
#generation = 0;
|
|
40
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
41
|
+
constructor(run) {
|
|
42
|
+
this.#run = run;
|
|
43
|
+
}
|
|
44
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
45
|
+
activate() {
|
|
46
|
+
this.#active = true;
|
|
47
|
+
}
|
|
48
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
49
|
+
cancel() {
|
|
50
|
+
this.#active = false;
|
|
51
|
+
this.#queued = false;
|
|
52
|
+
this.#generation += 1;
|
|
53
|
+
}
|
|
54
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
55
|
+
schedule() {
|
|
56
|
+
if (!this.#active || this.#queued) return;
|
|
57
|
+
this.#queued = true;
|
|
58
|
+
const generation = this.#generation;
|
|
59
|
+
queueMicrotask(() => {
|
|
60
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
61
|
+
this.#queued = false;
|
|
62
|
+
this.#run();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
5
67
|
// src/utils/safe_timeout.ts
|
|
6
68
|
var TimerRegistry = class {
|
|
7
69
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -73,11 +135,16 @@ var RelativeTimeController = class extends Controller {
|
|
|
73
135
|
tickInterval: { type: Number, default: 6e4 }
|
|
74
136
|
};
|
|
75
137
|
#timers = new SafeTimeout();
|
|
138
|
+
/** Collapses a morph that swaps several render inputs at once into one repaint. */
|
|
139
|
+
#resync = new MicrotaskCoalescer(() => this.#resyncToValues());
|
|
140
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
76
141
|
/** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */
|
|
77
142
|
#targetMs = Number.NaN;
|
|
78
143
|
/** The authored absolute text, restored when the threshold fallback kicks in. */
|
|
79
144
|
#absoluteText = "";
|
|
80
145
|
connect() {
|
|
146
|
+
this.#resync.activate();
|
|
147
|
+
this.#beforeCache.activate();
|
|
81
148
|
if (this.element.getAttribute("data-state") !== "relative") {
|
|
82
149
|
this.#absoluteText = (this.element.textContent ?? "").trim();
|
|
83
150
|
}
|
|
@@ -86,9 +153,50 @@ var RelativeTimeController = class extends Controller {
|
|
|
86
153
|
this.#schedule();
|
|
87
154
|
}
|
|
88
155
|
disconnect() {
|
|
156
|
+
this.#resync.cancel();
|
|
157
|
+
this.#beforeCache.deactivate();
|
|
158
|
+
this.#timers.clearAll();
|
|
159
|
+
}
|
|
160
|
+
/** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
|
|
161
|
+
localeValueChanged() {
|
|
162
|
+
this.#resync.schedule();
|
|
163
|
+
}
|
|
164
|
+
/** Repaints when application code (or a Turbo morph) changes `threshold` at runtime. */
|
|
165
|
+
thresholdValueChanged() {
|
|
166
|
+
this.#resync.schedule();
|
|
167
|
+
}
|
|
168
|
+
/** Repaints when application code (or a Turbo morph) changes `tickInterval` at runtime. */
|
|
169
|
+
tickIntervalValueChanged() {
|
|
170
|
+
this.#resync.schedule();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Renders against the current Values and re-arms the poll from now.
|
|
174
|
+
*
|
|
175
|
+
* Render only: it emits no event, and clearing first keeps the single self-arming
|
|
176
|
+
* timer single — scheduling on top of a pending one would double the poll rate for
|
|
177
|
+
* the rest of the session. A stamp whose `datetime` never parsed has nothing to
|
|
178
|
+
* render, and one that already reached its terminal fallback simply renders it
|
|
179
|
+
* again and stops.
|
|
180
|
+
*/
|
|
181
|
+
#resyncToValues() {
|
|
182
|
+
if (Number.isNaN(this.#targetMs)) return;
|
|
89
183
|
this.#timers.clearAll();
|
|
184
|
+
this.#schedule();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Restores the authored absolute text and the pre-render state for the snapshot
|
|
188
|
+
* Turbo is about to take, leaving the live page's poll timer alone.
|
|
189
|
+
*
|
|
190
|
+
* With no authored text held there is nothing to restore, and `data-state` has to
|
|
191
|
+
* stay as it is: that marker is what tells the next `connect()` the visible text is
|
|
192
|
+
* a rendered relative form rather than an absolute fallback to hold on to.
|
|
193
|
+
*/
|
|
194
|
+
#rewindForCache() {
|
|
195
|
+
if (!this.#absoluteText) return;
|
|
196
|
+
this.element.textContent = this.#absoluteText;
|
|
197
|
+
this.element.removeAttribute("data-state");
|
|
90
198
|
}
|
|
91
|
-
/** Renders the current representation and reschedules unless
|
|
199
|
+
/** Renders the current representation and reschedules unless polling can stop. */
|
|
92
200
|
#schedule() {
|
|
93
201
|
const nextDelay = this.#applyAndComputeDelay();
|
|
94
202
|
if (nextDelay !== null) {
|
|
@@ -96,31 +204,44 @@ var RelativeTimeController = class extends Controller {
|
|
|
96
204
|
}
|
|
97
205
|
}
|
|
98
206
|
/**
|
|
99
|
-
* Updates the visible text and returns the next poll delay (ms), or `null`
|
|
100
|
-
*
|
|
207
|
+
* Updates the visible text and returns the next poll delay (ms), or `null` when
|
|
208
|
+
* polling can stop: a *past* timestamp that fell back to the absolute text can
|
|
209
|
+
* never leave it again, and a locale the runtime rejects has nothing to render
|
|
210
|
+
* until that value is corrected.
|
|
101
211
|
*/
|
|
102
212
|
#applyAndComputeDelay() {
|
|
103
213
|
const deltaMs = this.#targetMs - Date.now();
|
|
104
214
|
const absSeconds = Math.abs(deltaMs) / 1e3;
|
|
215
|
+
const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
|
|
216
|
+
const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
|
|
217
|
+
const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
105
218
|
if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
|
|
106
219
|
this.element.textContent = this.#absoluteText;
|
|
107
220
|
this.element.setAttribute("data-state", "absolute");
|
|
108
|
-
return null;
|
|
221
|
+
if (deltaMs <= 0) return null;
|
|
222
|
+
return Math.min(nextDelay, deltaMs - this.thresholdValue * 1e3 + 1);
|
|
109
223
|
}
|
|
110
|
-
const
|
|
224
|
+
const formatter = this.#formatter;
|
|
225
|
+
if (formatter === null) return null;
|
|
111
226
|
const value = Math.round(deltaMs / scale.ms);
|
|
112
|
-
this.element.textContent =
|
|
227
|
+
this.element.textContent = formatter.format(value, scale.unit);
|
|
113
228
|
this.element.setAttribute("data-state", "relative");
|
|
114
|
-
|
|
115
|
-
return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
229
|
+
return nextDelay;
|
|
116
230
|
}
|
|
117
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`), or `null`
|
|
233
|
+
* when the runtime rejects that locale.
|
|
234
|
+
*/
|
|
118
235
|
get #formatter() {
|
|
119
|
-
|
|
236
|
+
try {
|
|
237
|
+
return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
120
241
|
}
|
|
121
|
-
/** Locale precedence: the value, then the
|
|
242
|
+
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
122
243
|
get #locale() {
|
|
123
|
-
return this.localeValue || this.element.lang
|
|
244
|
+
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
124
245
|
}
|
|
125
246
|
};
|
|
126
247
|
|