stimeo-ui 0.14.0 → 0.16.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 +212 -0
- data/README.md +120 -0
- data/dist/cable/index.js +123 -29
- data/dist/controllers/accordion_controller.js +98 -9
- data/dist/controllers/announcer_controller.js +96 -62
- data/dist/controllers/auto_submit_controller.js +83 -7
- data/dist/controllers/avatar_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +38 -11
- data/dist/controllers/bulk_select_controller.js +7 -6
- data/dist/controllers/calendar_controller.js +340 -123
- data/dist/controllers/carousel_controller.js +263 -38
- data/dist/controllers/character_counter_controller.js +40 -2
- data/dist/controllers/checkbox_controller.js +81 -12
- data/dist/controllers/clipboard_controller.js +63 -5
- data/dist/controllers/collapsible_controller.js +99 -14
- data/dist/controllers/color_picker_controller.js +80 -34
- data/dist/controllers/combobox_controller.js +106 -16
- data/dist/controllers/command_palette_controller.js +35 -3
- data/dist/controllers/conditional_fields_controller.js +85 -17
- data/dist/controllers/confirm_controller.js +3 -0
- data/dist/controllers/context_menu_controller.js +32 -12
- data/dist/controllers/countdown_controller.js +129 -26
- data/dist/controllers/currency_input_controller.js +221 -67
- data/dist/controllers/data_grid_controller.js +195 -29
- data/dist/controllers/date_range_picker_controller.js +151 -30
- data/dist/controllers/dialog_controller.js +35 -8
- data/dist/controllers/direct_upload_controller.js +22 -4
- data/dist/controllers/dirty_form_controller.js +46 -13
- data/dist/controllers/dismissible_controller.js +1 -0
- data/dist/controllers/drawer_controller.js +54 -19
- data/dist/controllers/dropdown_controller.js +36 -9
- data/dist/controllers/editable_controller.js +34 -0
- data/dist/controllers/file_dropzone_controller.js +144 -51
- data/dist/controllers/filter_controller.js +20 -6
- data/dist/controllers/flash_controller.js +432 -71
- data/dist/controllers/focus_controller.js +1 -0
- data/dist/controllers/form_field_controller.js +7 -5
- data/dist/controllers/form_validation_controller.js +19 -13
- data/dist/controllers/frame_loading_controller.js +45 -8
- data/dist/controllers/highlight_controller.js +82 -25
- data/dist/controllers/hover_card_controller.js +40 -14
- data/dist/controllers/idle_controller.js +90 -5
- data/dist/controllers/input_mask_controller.js +65 -9
- data/dist/controllers/intersection_controller.js +3 -0
- data/dist/controllers/lazy_frame_controller.js +11 -2
- data/dist/controllers/listbox_controller.js +203 -45
- data/dist/controllers/local_time_controller.js +10 -5
- data/dist/controllers/masonry_controller.js +31 -15
- data/dist/controllers/menu_controller.js +45 -16
- data/dist/controllers/menubar_controller.js +58 -24
- data/dist/controllers/meter_controller.js +9 -5
- data/dist/controllers/multi_select_controller.js +278 -104
- data/dist/controllers/navigation_menu_controller.js +48 -15
- data/dist/controllers/nested_form_controller.js +37 -8
- data/dist/controllers/network_status_controller.js +9 -1
- data/dist/controllers/number_input_controller.js +124 -21
- data/dist/controllers/optimistic_controller.js +42 -5
- data/dist/controllers/otp_controller.js +198 -55
- data/dist/controllers/overflow_indicator_controller.js +115 -21
- data/dist/controllers/overflow_menu_controller.js +141 -44
- data/dist/controllers/pagination_controller.js +74 -28
- data/dist/controllers/password_reveal_controller.js +59 -2
- data/dist/controllers/persist_controller.js +30 -8
- data/dist/controllers/pointer_drag_controller.js +131 -52
- data/dist/controllers/popover_controller.js +45 -11
- data/dist/controllers/portal_controller.js +6 -2
- data/dist/controllers/preview_guard_controller.js +16 -1
- data/dist/controllers/progress_controller.js +8 -4
- data/dist/controllers/radio_group_controller.js +42 -17
- data/dist/controllers/range_slider_controller.js +88 -42
- data/dist/controllers/rating_controller.js +39 -15
- data/dist/controllers/read_more_controller.js +100 -7
- data/dist/controllers/reading_progress_controller.js +65 -19
- data/dist/controllers/relative_time_controller.js +10 -5
- data/dist/controllers/resizable_controller.js +82 -22
- data/dist/controllers/scroll_area_controller.js +75 -27
- data/dist/controllers/scroll_restore_controller.js +37 -16
- data/dist/controllers/scroll_visibility_controller.js +49 -30
- data/dist/controllers/scrollspy_controller.js +71 -26
- data/dist/controllers/separator_controller.js +66 -37
- data/dist/controllers/sidebar_controller.js +77 -18
- data/dist/controllers/skeleton_controller.js +6 -1
- data/dist/controllers/slider_controller.js +82 -47
- data/dist/controllers/smart_sticky_header_controller.js +60 -26
- data/dist/controllers/sortable_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +10 -2
- data/dist/controllers/step_indicator_controller.js +18 -17
- data/dist/controllers/stepper_controller.js +101 -19
- data/dist/controllers/stick_to_bottom_controller.js +104 -8
- data/dist/controllers/submit_once_controller.js +45 -9
- data/dist/controllers/switch_controller.js +101 -10
- data/dist/controllers/tabs_controller.js +21 -2
- data/dist/controllers/tags_input_controller.js +209 -59
- data/dist/controllers/textarea_autosize_controller.js +29 -3
- data/dist/controllers/theme_controller.js +64 -14
- data/dist/controllers/time_picker_controller.js +23 -8
- data/dist/controllers/toast_controller.js +451 -105
- data/dist/controllers/toggle_group_controller.js +159 -23
- data/dist/controllers/toolbar_controller.js +32 -0
- data/dist/controllers/tooltip_controller.js +39 -13
- data/dist/controllers/transition_controller.js +4 -0
- data/dist/controllers/tree_view_controller.js +169 -16
- data/dist/index.js +5002 -1911
- data/dist/positioning/index.js +2 -0
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/stepper_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/microtask_coalescer.ts
|
|
6
|
+
var MicrotaskCoalescer = class {
|
|
7
|
+
#run;
|
|
8
|
+
#queued = false;
|
|
9
|
+
#active = false;
|
|
10
|
+
#generation = 0;
|
|
11
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
12
|
+
constructor(run) {
|
|
13
|
+
this.#run = run;
|
|
14
|
+
}
|
|
15
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
16
|
+
activate() {
|
|
17
|
+
this.#active = true;
|
|
18
|
+
}
|
|
19
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
20
|
+
cancel() {
|
|
21
|
+
this.#active = false;
|
|
22
|
+
this.#queued = false;
|
|
23
|
+
this.#generation += 1;
|
|
24
|
+
}
|
|
25
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
26
|
+
schedule() {
|
|
27
|
+
if (!this.#active || this.#queued) return;
|
|
28
|
+
this.#queued = true;
|
|
29
|
+
const generation = this.#generation;
|
|
30
|
+
queueMicrotask(() => {
|
|
31
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
32
|
+
this.#queued = false;
|
|
33
|
+
this.#run();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
3
38
|
// src/controllers/stepper_controller.ts
|
|
4
39
|
var StepperController = class extends Controller {
|
|
5
40
|
static targets = ["step"];
|
|
@@ -8,20 +43,39 @@ var StepperController = class extends Controller {
|
|
|
8
43
|
linear: { type: Boolean, default: false }
|
|
9
44
|
};
|
|
10
45
|
static actions = ["goto", "next", "prev"];
|
|
11
|
-
static events = ["change"];
|
|
12
|
-
|
|
13
|
-
|
|
46
|
+
static events = ["change", "reconcile"];
|
|
47
|
+
/**
|
|
48
|
+
* Collapses the Value and target callbacks of one mutation into one pass, and
|
|
49
|
+
* refuses the ones Stimulus delivers before `connect()`, which renders itself.
|
|
50
|
+
*/
|
|
51
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcileStep());
|
|
52
|
+
/**
|
|
53
|
+
* The position shown current last, which the next move is measured from, or
|
|
54
|
+
* `null` before any step has been shown. While no step is present it keeps the
|
|
55
|
+
* last one.
|
|
56
|
+
*/
|
|
57
|
+
#shown = null;
|
|
58
|
+
/** Renders the initial state from `index`, clamped into the step range. */
|
|
14
59
|
connect() {
|
|
15
|
-
this.#
|
|
16
|
-
this.#
|
|
60
|
+
this.#repaint.activate();
|
|
61
|
+
this.#shown = this.#currentStep;
|
|
62
|
+
this.#render();
|
|
17
63
|
}
|
|
64
|
+
/** Drops a pending pass, so nothing renders or reports for a stepper that left. */
|
|
18
65
|
disconnect() {
|
|
19
|
-
this.#
|
|
66
|
+
this.#repaint.cancel();
|
|
20
67
|
}
|
|
21
68
|
/** Re-renders when Turbo Morph or application code changes `index` at runtime. */
|
|
22
69
|
indexValueChanged() {
|
|
23
|
-
|
|
24
|
-
|
|
70
|
+
this.#repaint.schedule();
|
|
71
|
+
}
|
|
72
|
+
/** Re-derives every step's state for a step added at runtime. */
|
|
73
|
+
stepTargetConnected() {
|
|
74
|
+
this.#repaint.schedule();
|
|
75
|
+
}
|
|
76
|
+
/** Re-derives every step's state for a step removed at runtime. */
|
|
77
|
+
stepTargetDisconnected() {
|
|
78
|
+
this.#repaint.schedule();
|
|
25
79
|
}
|
|
26
80
|
/** Advances to the next step (ignored at the last step). */
|
|
27
81
|
next() {
|
|
@@ -31,9 +85,16 @@ var StepperController = class extends Controller {
|
|
|
31
85
|
prev() {
|
|
32
86
|
this.#moveTo(this.#clampIndex(this.indexValue) - 1);
|
|
33
87
|
}
|
|
34
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Jumps to the step carried in the action's `index` param. A param that is empty,
|
|
90
|
+
* or that Stimulus parses into anything but a number or a string, is no index,
|
|
91
|
+
* so it jumps nowhere.
|
|
92
|
+
*/
|
|
35
93
|
goto(event) {
|
|
36
|
-
const
|
|
94
|
+
const { index } = event.params;
|
|
95
|
+
if (index === "") return;
|
|
96
|
+
if (typeof index !== "number" && typeof index !== "string") return;
|
|
97
|
+
const target = Number(index);
|
|
37
98
|
if (!Number.isFinite(target) || !Number.isInteger(target)) return;
|
|
38
99
|
this.#moveTo(target);
|
|
39
100
|
}
|
|
@@ -51,19 +112,39 @@ var StepperController = class extends Controller {
|
|
|
51
112
|
if (this.linearValue && target > current + 1) return;
|
|
52
113
|
const previous = current;
|
|
53
114
|
this.indexValue = target;
|
|
54
|
-
this.#
|
|
115
|
+
this.#shown = target;
|
|
116
|
+
this.#render();
|
|
55
117
|
this.dispatch("change", {
|
|
56
|
-
detail: { index: target, previous, step: this.stepTargets[target] }
|
|
118
|
+
detail: { index: target, previous, total, step: this.stepTargets[target] }
|
|
57
119
|
});
|
|
58
120
|
}
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Re-derives one settled batch of Value and target changes, and reports a
|
|
123
|
+
* current position that moved from the one shown before as `reconcile`. With no
|
|
124
|
+
* step present there is nothing to report, and the position shown last stays the
|
|
125
|
+
* one the next move is measured from.
|
|
126
|
+
*/
|
|
127
|
+
#reconcileStep() {
|
|
128
|
+
const previous = this.#shown;
|
|
129
|
+
const index = this.#currentStep;
|
|
130
|
+
if (index !== null) this.#shown = index;
|
|
131
|
+
this.#render();
|
|
132
|
+
if (index === null || previous === null || index === previous) return;
|
|
133
|
+
const steps = this.stepTargets;
|
|
134
|
+
this.dispatch("reconcile", {
|
|
135
|
+
detail: { index, previous, total: steps.length, step: steps[index] }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** The current position, clamped into the step range, or `null` when there is no step. */
|
|
139
|
+
get #currentStep() {
|
|
140
|
+
return this.stepTargets.length > 0 ? this.#clampIndex(this.indexValue) : null;
|
|
64
141
|
}
|
|
65
142
|
/**
|
|
66
|
-
* Derives each step's `data-state` and the current button's `aria-current
|
|
143
|
+
* Derives each step's `data-state` and the current button's `aria-current` from
|
|
144
|
+
* `index`, clamped into the step range, without dispatching an action event.
|
|
145
|
+
*
|
|
146
|
+
* The clamp decides only what is shown. This body never writes `index`, so an
|
|
147
|
+
* out-of-range declaration stays in the attribute as the page wrote it.
|
|
67
148
|
*
|
|
68
149
|
* `aria-current="step"` is placed on the step's **first** `<button>`; the markup
|
|
69
150
|
* contract assumes one operable button per step. If a step needs multiple
|
|
@@ -71,7 +152,8 @@ var StepperController = class extends Controller {
|
|
|
71
152
|
*
|
|
72
153
|
* @stimeoRenderRoot
|
|
73
154
|
*/
|
|
74
|
-
#render(
|
|
155
|
+
#render() {
|
|
156
|
+
const current = this.#clampIndex(this.indexValue);
|
|
75
157
|
this.stepTargets.forEach((step, index) => {
|
|
76
158
|
step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
|
|
77
159
|
const button = step.querySelector("button");
|
|
@@ -8,10 +8,15 @@ var LayoutObserver = class {
|
|
|
8
8
|
#resizeObserverFactory;
|
|
9
9
|
#resizeObserver = null;
|
|
10
10
|
#observingViewport = false;
|
|
11
|
+
#loadContainer = null;
|
|
11
12
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
12
13
|
#handleViewportResize = () => {
|
|
13
14
|
this.#callback();
|
|
14
15
|
};
|
|
16
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
17
|
+
#handleDescendantLoad = () => {
|
|
18
|
+
this.#callback();
|
|
19
|
+
};
|
|
15
20
|
constructor(callback, options = {}) {
|
|
16
21
|
this.#callback = callback;
|
|
17
22
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -47,14 +52,35 @@ var LayoutObserver = class {
|
|
|
47
52
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
48
53
|
}
|
|
49
54
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
+
observeDescendantLoads(container) {
|
|
65
|
+
this.unobserveDescendantLoads();
|
|
66
|
+
this.#loadContainer = container;
|
|
67
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
68
|
+
}
|
|
69
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
70
|
+
unobserveDescendantLoads() {
|
|
71
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
72
|
+
this.#loadContainer = null;
|
|
73
|
+
}
|
|
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()`.
|
|
53
78
|
*/
|
|
54
79
|
disconnect() {
|
|
55
80
|
this.#resizeObserver?.disconnect();
|
|
56
81
|
this.#resizeObserver = null;
|
|
57
82
|
this.unobserveViewport();
|
|
83
|
+
this.unobserveDescendantLoads();
|
|
58
84
|
}
|
|
59
85
|
};
|
|
60
86
|
|
|
@@ -63,6 +89,47 @@ function prefersReducedMotion() {
|
|
|
63
89
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
64
90
|
}
|
|
65
91
|
|
|
92
|
+
// src/utils/state_regions.ts
|
|
93
|
+
var StateRegions = class {
|
|
94
|
+
#whenTrue;
|
|
95
|
+
#whenFalse;
|
|
96
|
+
#taken = /* @__PURE__ */ new WeakSet();
|
|
97
|
+
constructor(options) {
|
|
98
|
+
this.#whenTrue = options.whenTrue;
|
|
99
|
+
this.#whenFalse = options.whenFalse ?? null;
|
|
100
|
+
}
|
|
101
|
+
/** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
|
|
102
|
+
reflect(host, isTrue) {
|
|
103
|
+
const shown = this.#inside(host, this.#whenTrue());
|
|
104
|
+
if (!this.#whenFalse) {
|
|
105
|
+
for (const region of shown) this.#write(region, !isTrue);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const hiddenSide = this.#inside(host, this.#whenFalse());
|
|
109
|
+
if (shown.length === 0 || hiddenSide.length === 0) {
|
|
110
|
+
for (const region of shown) this.#give(region);
|
|
111
|
+
for (const region of hiddenSide) this.#give(region);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const region of shown) this.#write(region, !isTrue);
|
|
115
|
+
for (const region of hiddenSide) this.#write(region, isTrue);
|
|
116
|
+
}
|
|
117
|
+
/** Writes `hidden` where it moves, noting which regions it takes out of view. */
|
|
118
|
+
#write(region, hidden) {
|
|
119
|
+
if (hidden) this.#taken.add(region);
|
|
120
|
+
else this.#taken.delete(region);
|
|
121
|
+
if (region.hidden !== hidden) region.hidden = hidden;
|
|
122
|
+
}
|
|
123
|
+
/** Returns a region this instance hid; one it never hid keeps what it carries. */
|
|
124
|
+
#give(region) {
|
|
125
|
+
if (this.#taken.delete(region)) region.hidden = false;
|
|
126
|
+
}
|
|
127
|
+
/** The declared regions that sit within `host`, which may be the host itself. */
|
|
128
|
+
#inside(host, regions) {
|
|
129
|
+
return regions.filter((region) => host.contains(region));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
66
133
|
// src/controllers/stick_to_bottom_controller.ts
|
|
67
134
|
var DEFAULT_THRESHOLD = 80;
|
|
68
135
|
var countElements = (nodes) => {
|
|
@@ -71,7 +138,7 @@ var countElements = (nodes) => {
|
|
|
71
138
|
return n;
|
|
72
139
|
};
|
|
73
140
|
var StickToBottomController = class extends Controller {
|
|
74
|
-
static targets = ["content"];
|
|
141
|
+
static targets = ["content", "hasNew"];
|
|
75
142
|
static values = {
|
|
76
143
|
threshold: { type: Number, default: DEFAULT_THRESHOLD },
|
|
77
144
|
behavior: { type: String, default: "auto" },
|
|
@@ -87,11 +154,14 @@ var StickToBottomController = class extends Controller {
|
|
|
87
154
|
#awaitingLayout = false;
|
|
88
155
|
#connected = false;
|
|
89
156
|
#pinned = false;
|
|
157
|
+
#hasNew = false;
|
|
158
|
+
/** Owns `hidden` on the regions declared for the has-new state. */
|
|
159
|
+
#hasNewRegion = new StateRegions({ whenTrue: () => this.hasNewTargets });
|
|
90
160
|
#onScroll = () => this.#updatePinned();
|
|
91
161
|
connect() {
|
|
92
162
|
this.#connected = true;
|
|
93
163
|
if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
|
|
94
|
-
this
|
|
164
|
+
this.#setHasNew(false);
|
|
95
165
|
this.#pinned = this.#isPinned();
|
|
96
166
|
this.#reflectPinned();
|
|
97
167
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
@@ -112,6 +182,19 @@ var StickToBottomController = class extends Controller {
|
|
|
112
182
|
contentTargetDisconnected() {
|
|
113
183
|
this.#syncWatched();
|
|
114
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Settles a has-new region that arrived at runtime on the side the flag is currently
|
|
187
|
+
* on, the way a region present at connect is settled by the connect-time normalization.
|
|
188
|
+
*
|
|
189
|
+
* Stimulus attaches targets before `connect()`, and a container that leaves the document
|
|
190
|
+
* and comes back keeps its controller, so in that window the flag still reads the
|
|
191
|
+
* arrival of the connection that ended. A region attached there is left to the
|
|
192
|
+
* connect-time normalization, which drops the flag and settles the region in one write.
|
|
193
|
+
*/
|
|
194
|
+
hasNewTargetConnected() {
|
|
195
|
+
if (!this.#connected) return;
|
|
196
|
+
this.#hasNewRegion.reflect(this.element, this.#hasNew);
|
|
197
|
+
}
|
|
115
198
|
/**
|
|
116
199
|
* Re-derives pinned when the distance that counts as the bottom is changed at runtime
|
|
117
200
|
* (a morph that swaps the attribute on a retained element).
|
|
@@ -133,7 +216,7 @@ var StickToBottomController = class extends Controller {
|
|
|
133
216
|
*/
|
|
134
217
|
scrollToBottom() {
|
|
135
218
|
this.#scrollToBottom();
|
|
136
|
-
this
|
|
219
|
+
this.#setHasNew(false);
|
|
137
220
|
this.#updatePinned();
|
|
138
221
|
}
|
|
139
222
|
/** Follows appended children while pinned; otherwise flags new content. */
|
|
@@ -144,7 +227,7 @@ var StickToBottomController = class extends Controller {
|
|
|
144
227
|
if (this.#pinned) {
|
|
145
228
|
this.#scrollToBottom();
|
|
146
229
|
} else {
|
|
147
|
-
this
|
|
230
|
+
this.#setHasNew(true);
|
|
148
231
|
this.dispatch("new", { detail: { count: added } });
|
|
149
232
|
}
|
|
150
233
|
}
|
|
@@ -164,11 +247,24 @@ var StickToBottomController = class extends Controller {
|
|
|
164
247
|
#reflectPinned() {
|
|
165
248
|
if (this.#pinned) {
|
|
166
249
|
this.element.setAttribute("data-pinned", "true");
|
|
167
|
-
this
|
|
250
|
+
this.#setHasNew(false);
|
|
168
251
|
} else {
|
|
169
252
|
this.element.removeAttribute("data-pinned");
|
|
170
253
|
}
|
|
171
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Mirrors whether content has arrived that the container did not follow onto the state
|
|
257
|
+
* hook and onto the regions declared for that state.
|
|
258
|
+
*/
|
|
259
|
+
#setHasNew(hasNew) {
|
|
260
|
+
this.#hasNew = hasNew;
|
|
261
|
+
if (hasNew) {
|
|
262
|
+
this.element.setAttribute("data-has-new", "true");
|
|
263
|
+
} else {
|
|
264
|
+
this.element.removeAttribute("data-has-new");
|
|
265
|
+
}
|
|
266
|
+
this.#hasNewRegion.reflect(this.element, hasNew);
|
|
267
|
+
}
|
|
172
268
|
/** Whether the container currently sits within `threshold` of its bottom. */
|
|
173
269
|
#isPinned() {
|
|
174
270
|
if (!this.#measurable()) return false;
|
|
@@ -138,6 +138,30 @@ var DetachGate = class _DetachGate {
|
|
|
138
138
|
}
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
+
// src/utils/listener_set.ts
|
|
142
|
+
var ListenerSet = class {
|
|
143
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
144
|
+
#abort = new AbortController();
|
|
145
|
+
/**
|
|
146
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
147
|
+
*
|
|
148
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
149
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
150
|
+
* longer has a second place to drift from.
|
|
151
|
+
*/
|
|
152
|
+
add(target, type, handler, options) {
|
|
153
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
157
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
158
|
+
*/
|
|
159
|
+
dispose() {
|
|
160
|
+
this.#abort.abort();
|
|
161
|
+
this.#abort = new AbortController();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
141
165
|
// src/utils/safe_timeout.ts
|
|
142
166
|
var TimerRegistry = class {
|
|
143
167
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -206,6 +230,7 @@ var SubmitOnceController = class extends Controller {
|
|
|
206
230
|
static events = ["start", "end", "reconcile"];
|
|
207
231
|
#timers = new SafeTimeout();
|
|
208
232
|
#gate = new DetachGate();
|
|
233
|
+
#listeners = new ListenerSet();
|
|
209
234
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
210
235
|
#sessions = /* @__PURE__ */ new Map();
|
|
211
236
|
#disabled = new AttributeLease("disabled");
|
|
@@ -230,14 +255,12 @@ var SubmitOnceController = class extends Controller {
|
|
|
230
255
|
connect() {
|
|
231
256
|
this.#gate.cancel();
|
|
232
257
|
this.#beforeCache.activate();
|
|
233
|
-
this.element
|
|
234
|
-
this.element
|
|
235
|
-
this.element
|
|
258
|
+
this.#listeners.add(this.element, "submit", this.#onNativeSubmit, { capture: true });
|
|
259
|
+
this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
|
|
260
|
+
this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
|
|
236
261
|
}
|
|
237
262
|
disconnect() {
|
|
238
|
-
this.
|
|
239
|
-
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
240
|
-
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
263
|
+
this.#listeners.dispose();
|
|
241
264
|
this.#beforeCache.deactivate();
|
|
242
265
|
this.#gate.disconnected(this, () => this.#teardown());
|
|
243
266
|
}
|
|
@@ -308,7 +331,12 @@ var SubmitOnceController = class extends Controller {
|
|
|
308
331
|
if (!session || !this.#isNativeSubmitControl(target)) return;
|
|
309
332
|
this.#enterControl(session, target);
|
|
310
333
|
}
|
|
311
|
-
/**
|
|
334
|
+
/**
|
|
335
|
+
* Ends one session, restores owned state, and reports the observable completion.
|
|
336
|
+
*
|
|
337
|
+
* @stimeoRuntimeOnly `announceReadyText` words the one announcement of this finished submission
|
|
338
|
+
* and `restoreFocus` decides where its focus returns.
|
|
339
|
+
*/
|
|
312
340
|
#complete(form, reason, success) {
|
|
313
341
|
const session = this.#sessionFor(form);
|
|
314
342
|
if (!session) return;
|
|
@@ -330,7 +358,12 @@ var SubmitOnceController = class extends Controller {
|
|
|
330
358
|
this.#disabled.write(control, "");
|
|
331
359
|
this.#controlBusy.write(control, "true");
|
|
332
360
|
}
|
|
333
|
-
/**
|
|
361
|
+
/**
|
|
362
|
+
* Swaps only the triggering control's safe label channel.
|
|
363
|
+
*
|
|
364
|
+
* @stimeoRuntimeOnly `busyLabel` is the label this one submission shows until it ends; the idle
|
|
365
|
+
* label comes back with the state it returns.
|
|
366
|
+
*/
|
|
334
367
|
#enterLabel(session) {
|
|
335
368
|
const control = session.submitter;
|
|
336
369
|
if (!control) return;
|
|
@@ -417,7 +450,10 @@ var SubmitOnceController = class extends Controller {
|
|
|
417
450
|
}
|
|
418
451
|
this.#sessions.clear();
|
|
419
452
|
}
|
|
420
|
-
/**
|
|
453
|
+
/**
|
|
454
|
+
* Keeps Turbo's cached snapshot idle without `end`, announcements, or focus
|
|
455
|
+
* moves; the abandoned forms are reported once as `reconcile`.
|
|
456
|
+
*/
|
|
421
457
|
#rewindForCache() {
|
|
422
458
|
const forms = [...this.#sessions.keys()];
|
|
423
459
|
this.#abandonSessions();
|
|
@@ -9,6 +9,16 @@ function setDefaultAttribute(element, name, value) {
|
|
|
9
9
|
return true;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// src/utils/field_mirror.ts
|
|
13
|
+
function writeField(field, value) {
|
|
14
|
+
if (field.value === value) return false;
|
|
15
|
+
field.value = value;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
function commitField(target) {
|
|
19
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
// src/utils/focus_candidate.ts
|
|
13
23
|
function inheritsFieldsetDisabled(control) {
|
|
14
24
|
let fieldset = control.closest("fieldset[disabled]");
|
|
@@ -37,6 +47,39 @@ function isInteractiveHost(element) {
|
|
|
37
47
|
return false;
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
// src/utils/microtask_coalescer.ts
|
|
51
|
+
var MicrotaskCoalescer = class {
|
|
52
|
+
#run;
|
|
53
|
+
#queued = false;
|
|
54
|
+
#active = false;
|
|
55
|
+
#generation = 0;
|
|
56
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
57
|
+
constructor(run) {
|
|
58
|
+
this.#run = run;
|
|
59
|
+
}
|
|
60
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
61
|
+
activate() {
|
|
62
|
+
this.#active = true;
|
|
63
|
+
}
|
|
64
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
65
|
+
cancel() {
|
|
66
|
+
this.#active = false;
|
|
67
|
+
this.#queued = false;
|
|
68
|
+
this.#generation += 1;
|
|
69
|
+
}
|
|
70
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
71
|
+
schedule() {
|
|
72
|
+
if (!this.#active || this.#queued) return;
|
|
73
|
+
this.#queued = true;
|
|
74
|
+
const generation = this.#generation;
|
|
75
|
+
queueMicrotask(() => {
|
|
76
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
77
|
+
this.#queued = false;
|
|
78
|
+
this.#run();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
40
83
|
// src/controllers/switch_controller.ts
|
|
41
84
|
var OBSERVED_ATTRIBUTES = [
|
|
42
85
|
"role",
|
|
@@ -49,17 +92,20 @@ var OBSERVED_ATTRIBUTES = [
|
|
|
49
92
|
];
|
|
50
93
|
var OBSERVED_ANCESTOR_ATTRIBUTES = ["contenteditable"];
|
|
51
94
|
var SwitchController = class extends Controller {
|
|
95
|
+
static targets = ["field"];
|
|
52
96
|
static actions = ["onKeydown", "toggle"];
|
|
53
|
-
static events = ["
|
|
97
|
+
static events = ["change", "reconcile"];
|
|
54
98
|
/** Defaults this instance introduced and may therefore remove safely. */
|
|
55
99
|
#ownedDefaults = /* @__PURE__ */ new Set();
|
|
56
100
|
/** Controller writes that must not be mistaken for authored morph changes. */
|
|
57
101
|
#internalAttributeValues = /* @__PURE__ */ new Map();
|
|
102
|
+
/** One pass per batch of retained-element changes and field arrivals. */
|
|
103
|
+
#pass = new MicrotaskCoalescer(() => this.#reconcile());
|
|
104
|
+
/** The checked state last published: read on connect, then committed or reported. */
|
|
105
|
+
#committedChecked = false;
|
|
58
106
|
#attributeObserver = new MutationObserver((records) => {
|
|
59
107
|
this.#releaseAuthoredDefaults(records);
|
|
60
|
-
this.#
|
|
61
|
-
this.#reconcileDefaults();
|
|
62
|
-
this.#observeAttributes();
|
|
108
|
+
this.#pass.schedule();
|
|
63
109
|
});
|
|
64
110
|
/** Blocks disabled pointer/native-key activation before consumer click handlers. */
|
|
65
111
|
#onClickCapture = (event) => {
|
|
@@ -67,14 +113,25 @@ var SwitchController = class extends Controller {
|
|
|
67
113
|
event.preventDefault();
|
|
68
114
|
event.stopImmediatePropagation();
|
|
69
115
|
};
|
|
70
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Ensures the switch exposes a role and is keyboard-reachable, and takes the
|
|
118
|
+
* state it finds as the one a later move is measured from, reporting nothing.
|
|
119
|
+
*/
|
|
71
120
|
connect() {
|
|
72
121
|
this.#reconcileDefaults();
|
|
122
|
+
this.#mirrorField(false);
|
|
123
|
+
this.#committedChecked = this.#checked;
|
|
73
124
|
this.element.addEventListener("click", this.#onClickCapture, true);
|
|
125
|
+
this.#pass.activate();
|
|
74
126
|
this.#observeAttributes();
|
|
75
127
|
}
|
|
76
|
-
/**
|
|
128
|
+
/** Fills a form field inserted or replaced at runtime with the current state. */
|
|
129
|
+
fieldTargetConnected() {
|
|
130
|
+
this.#pass.schedule();
|
|
131
|
+
}
|
|
132
|
+
/** Releases the click guard, the attribute observer, and a pass still queued. */
|
|
77
133
|
disconnect() {
|
|
134
|
+
this.#pass.cancel();
|
|
78
135
|
this.element.removeEventListener("click", this.#onClickCapture, true);
|
|
79
136
|
this.#attributeObserver.disconnect();
|
|
80
137
|
this.#internalAttributeValues.clear();
|
|
@@ -82,7 +139,7 @@ var SwitchController = class extends Controller {
|
|
|
82
139
|
/** Toggles the checked state. Bound via `data-action` (click). */
|
|
83
140
|
toggle() {
|
|
84
141
|
if (!this.#isSupportedHost || this.#isActivationDisabled) return;
|
|
85
|
-
this.#
|
|
142
|
+
this.#commit(!this.#checked);
|
|
86
143
|
}
|
|
87
144
|
/**
|
|
88
145
|
* Activates the switch on Space/Enter for non-native hosts and suppresses key
|
|
@@ -107,6 +164,28 @@ var SwitchController = class extends Controller {
|
|
|
107
164
|
if (event.repeat) return;
|
|
108
165
|
this.toggle();
|
|
109
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Re-derives the defaults and the field after the page changed the host, then
|
|
169
|
+
* reports a checked state that moved. Records still queued are read first, so
|
|
170
|
+
* an author's write is not lost when observation is suspended for this pass's
|
|
171
|
+
* own writes; observation resumes before the report, so an edit a listener
|
|
172
|
+
* makes is seen by the next pass.
|
|
173
|
+
*/
|
|
174
|
+
#reconcile() {
|
|
175
|
+
this.#releaseAuthoredDefaults(this.#attributeObserver.takeRecords());
|
|
176
|
+
this.#attributeObserver.disconnect();
|
|
177
|
+
this.#reconcileDefaults();
|
|
178
|
+
this.#mirrorField(false);
|
|
179
|
+
this.#observeAttributes();
|
|
180
|
+
this.#reportMove();
|
|
181
|
+
}
|
|
182
|
+
/** Reports a checked state that moved since the one this switch last published. */
|
|
183
|
+
#reportMove() {
|
|
184
|
+
const checked = this.#checked;
|
|
185
|
+
if (checked === this.#committedChecked) return;
|
|
186
|
+
this.#committedChecked = checked;
|
|
187
|
+
this.dispatch("reconcile", { detail: { checked } });
|
|
188
|
+
}
|
|
110
189
|
/** Re-adds only missing defaults, preserving every authored attribute value. */
|
|
111
190
|
#reconcileDefaults() {
|
|
112
191
|
if (!this.#isSupportedHost) {
|
|
@@ -119,6 +198,12 @@ var SwitchController = class extends Controller {
|
|
|
119
198
|
this.#setOwnedDefault("tabindex", "0");
|
|
120
199
|
}
|
|
121
200
|
}
|
|
201
|
+
/** Mirrors the checked state into the optional form field. */
|
|
202
|
+
#mirrorField(notify) {
|
|
203
|
+
if (!this.hasFieldTarget) return;
|
|
204
|
+
const moved = writeField(this.fieldTarget, this.#checked ? "true" : "false");
|
|
205
|
+
if (moved && notify) commitField(this.fieldTarget);
|
|
206
|
+
}
|
|
122
207
|
/** Records a newly introduced default without claiming authored markup. */
|
|
123
208
|
#setOwnedDefault(name, value) {
|
|
124
209
|
if (setDefaultAttribute(this.element, name, value)) this.#ownedDefaults.add(name);
|
|
@@ -181,12 +266,18 @@ var SwitchController = class extends Controller {
|
|
|
181
266
|
get #checked() {
|
|
182
267
|
return this.element.getAttribute("aria-checked") === "true";
|
|
183
268
|
}
|
|
184
|
-
/**
|
|
185
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Applies one toggle the user made. The state is taken as published before
|
|
271
|
+
* either report goes out, so a listener that toggles again or writes the
|
|
272
|
+
* attribute is measured from it.
|
|
273
|
+
*/
|
|
274
|
+
#commit(value) {
|
|
186
275
|
const reflected = value ? "true" : "false";
|
|
187
276
|
this.#internalAttributeValues.set("aria-checked", reflected);
|
|
188
277
|
this.element.setAttribute("aria-checked", reflected);
|
|
189
|
-
this
|
|
278
|
+
this.#committedChecked = value;
|
|
279
|
+
this.#mirrorField(true);
|
|
280
|
+
this.dispatch("change", { detail: { checked: value } });
|
|
190
281
|
}
|
|
191
282
|
};
|
|
192
283
|
|
|
@@ -17,6 +17,9 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
17
17
|
var TabsController = class extends Controller {
|
|
18
18
|
static targets = ["tab", "panel", "list"];
|
|
19
19
|
static actions = ["onKeydown", "select"];
|
|
20
|
+
static events = ["change"];
|
|
21
|
+
/** Whether selection moves are reported: set once `connect()` settled the baseline. */
|
|
22
|
+
#reporting = false;
|
|
20
23
|
/**
|
|
21
24
|
* Selects the initially active tab: the pre-selected one, else the first.
|
|
22
25
|
*
|
|
@@ -30,6 +33,14 @@ var TabsController = class extends Controller {
|
|
|
30
33
|
(tab) => tab.getAttribute("aria-selected") === "true"
|
|
31
34
|
);
|
|
32
35
|
this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });
|
|
36
|
+
this.#reporting = true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Stops reporting until the next `connect()` has settled its baseline, so that
|
|
40
|
+
* normalization stays silent.
|
|
41
|
+
*/
|
|
42
|
+
disconnect() {
|
|
43
|
+
this.#reporting = false;
|
|
33
44
|
}
|
|
34
45
|
/** Selects the clicked tab. Bound via `data-action` (click). */
|
|
35
46
|
select(event) {
|
|
@@ -69,7 +80,9 @@ var TabsController = class extends Controller {
|
|
|
69
80
|
* `tabindex`, and panel visibility. Optionally moves focus to the new tab.
|
|
70
81
|
*/
|
|
71
82
|
#selectIndex(index, { focus }) {
|
|
72
|
-
this.tabTargets
|
|
83
|
+
const tabs = this.tabTargets;
|
|
84
|
+
const previous = tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true");
|
|
85
|
+
tabs.forEach((tab, i) => {
|
|
73
86
|
const selected = i === index;
|
|
74
87
|
tab.setAttribute("aria-selected", selected ? "true" : "false");
|
|
75
88
|
tab.tabIndex = selected ? 0 : -1;
|
|
@@ -77,7 +90,13 @@ var TabsController = class extends Controller {
|
|
|
77
90
|
this.panelTargets.forEach((panel, i) => {
|
|
78
91
|
panel.hidden = i !== index;
|
|
79
92
|
});
|
|
80
|
-
if (
|
|
93
|
+
if (previous !== index && this.#reporting) {
|
|
94
|
+
this.dispatch("change", {
|
|
95
|
+
detail: { index, total: tabs.length, previous },
|
|
96
|
+
cancelable: false
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (focus && tabs[index]?.getAttribute("aria-selected") === "true") tabs[index]?.focus();
|
|
81
100
|
}
|
|
82
101
|
};
|
|
83
102
|
|