stimeo-ui 0.7.0 → 0.9.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 +151 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/data_grid_controller.js +150 -24
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +485 -114
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +2764 -923
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/reset_before_cache_controller.ts
|
|
4
|
+
var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
|
|
5
|
+
function restoreField(element) {
|
|
6
|
+
if (element instanceof HTMLTextAreaElement) {
|
|
7
|
+
element.value = element.defaultValue;
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (element instanceof HTMLSelectElement) {
|
|
11
|
+
for (const option of element.options) option.selected = option.hasAttribute("selected");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (element instanceof HTMLInputElement) {
|
|
15
|
+
if (element.type === "checkbox" || element.type === "radio") {
|
|
16
|
+
element.checked = element.defaultChecked;
|
|
17
|
+
} else if (element.type === "file") {
|
|
18
|
+
element.value = "";
|
|
19
|
+
} else if (!STATELESS_INPUT_TYPES.has(element.type)) {
|
|
20
|
+
element.value = element.defaultValue;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
4
24
|
var ResetBeforeCacheController = class extends Controller {
|
|
5
25
|
static values = {
|
|
6
26
|
scope: { type: String, default: "" },
|
|
@@ -8,8 +28,32 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
8
28
|
};
|
|
9
29
|
static actions = ["reset"];
|
|
10
30
|
static events = ["reset", "request"];
|
|
31
|
+
/** The `scope` declaration after validation; empty when it cannot be parsed. */
|
|
32
|
+
#scopeSelector = "";
|
|
11
33
|
/** Runs the reset just before Turbo caches the snapshot. */
|
|
12
34
|
#onBeforeCache = () => this.reset();
|
|
35
|
+
/**
|
|
36
|
+
* Validates the scope declaration once, keeping only a selector the engine can
|
|
37
|
+
* read.
|
|
38
|
+
*
|
|
39
|
+
* A selector reads back as an ordinary string, so a malformed one survives
|
|
40
|
+
* until it is handed to the DOM — and this part runs from a single listener
|
|
41
|
+
* whose whole job is to keep a cached page from freezing. Falling back to the
|
|
42
|
+
* default keeps that job running with a visible, findable result instead of
|
|
43
|
+
* silently taking the sweep down.
|
|
44
|
+
*/
|
|
45
|
+
scopeValueChanged() {
|
|
46
|
+
const selector = this.scopeValue;
|
|
47
|
+
if (selector.length > 0) {
|
|
48
|
+
try {
|
|
49
|
+
this.element.matches(selector);
|
|
50
|
+
this.#scopeSelector = selector;
|
|
51
|
+
return;
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
this.#scopeSelector = "";
|
|
56
|
+
}
|
|
13
57
|
connect() {
|
|
14
58
|
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
15
59
|
}
|
|
@@ -20,6 +64,10 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
20
64
|
* Resets transient UI within scope to its initial state. Asks controllers to
|
|
21
65
|
* close (via `request`) first, then applies the declarative `data-reset-*` cleanup,
|
|
22
66
|
* and finally emits `reset`. Safe to call any number of times (idempotent).
|
|
67
|
+
*
|
|
68
|
+
* `dispatchReset` decides only whether the `request` ask goes out; the cleanup
|
|
69
|
+
* and the closing `reset` run either way. Both the listener and this action
|
|
70
|
+
* reach the same sweep, so `reset` is emitted for a manual call too.
|
|
23
71
|
*/
|
|
24
72
|
reset() {
|
|
25
73
|
const root = this.#scopeRoot();
|
|
@@ -38,9 +86,7 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
38
86
|
if (element instanceof HTMLFormElement) element.reset();
|
|
39
87
|
}
|
|
40
88
|
for (const element of root.querySelectorAll("[data-reset-value]")) {
|
|
41
|
-
|
|
42
|
-
element.value = "";
|
|
43
|
-
}
|
|
89
|
+
restoreField(element);
|
|
44
90
|
}
|
|
45
91
|
for (const element of root.querySelectorAll("[data-reset-hidden]")) {
|
|
46
92
|
element.hidden = true;
|
|
@@ -52,8 +98,8 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
52
98
|
}
|
|
53
99
|
/** The scan root: a `scope` descendant when set, else the controller element. */
|
|
54
100
|
#scopeRoot() {
|
|
55
|
-
if (!this
|
|
56
|
-
return this.element.querySelector(this
|
|
101
|
+
if (!this.#scopeSelector) return this.element;
|
|
102
|
+
return this.element.querySelector(this.#scopeSelector) ?? this.element;
|
|
57
103
|
}
|
|
58
104
|
};
|
|
59
105
|
|
|
@@ -8,6 +8,39 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
8
8
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/utils/microtask_coalescer.ts
|
|
12
|
+
var MicrotaskCoalescer = class {
|
|
13
|
+
#run;
|
|
14
|
+
#queued = false;
|
|
15
|
+
#active = false;
|
|
16
|
+
#generation = 0;
|
|
17
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
18
|
+
constructor(run) {
|
|
19
|
+
this.#run = run;
|
|
20
|
+
}
|
|
21
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
22
|
+
activate() {
|
|
23
|
+
this.#active = true;
|
|
24
|
+
}
|
|
25
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
26
|
+
cancel() {
|
|
27
|
+
this.#active = false;
|
|
28
|
+
this.#queued = false;
|
|
29
|
+
this.#generation += 1;
|
|
30
|
+
}
|
|
31
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
32
|
+
schedule() {
|
|
33
|
+
if (!this.#active || this.#queued) return;
|
|
34
|
+
this.#queued = true;
|
|
35
|
+
const generation = this.#generation;
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
38
|
+
this.#queued = false;
|
|
39
|
+
this.#run();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
11
44
|
// src/utils/before_cache_reset.ts
|
|
12
45
|
var BeforeCacheReset = class _BeforeCacheReset {
|
|
13
46
|
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
@@ -69,26 +102,34 @@ var TabindexLoan = class {
|
|
|
69
102
|
};
|
|
70
103
|
|
|
71
104
|
// src/controllers/resizable_controller.ts
|
|
105
|
+
var DEFAULT_MIN = 0;
|
|
106
|
+
var DEFAULT_MAX = 100;
|
|
107
|
+
var DEFAULT_VALUE = 50;
|
|
108
|
+
var DEFAULT_STEP = 1;
|
|
72
109
|
var ResizableController = class extends Controller {
|
|
73
110
|
static targets = ["primary", "secondary", "separator"];
|
|
74
111
|
static values = {
|
|
75
|
-
min: { type: Number, default:
|
|
76
|
-
max: { type: Number, default:
|
|
77
|
-
step: { type: Number, default:
|
|
78
|
-
value: { type: Number, default:
|
|
112
|
+
min: { type: Number, default: DEFAULT_MIN },
|
|
113
|
+
max: { type: Number, default: DEFAULT_MAX },
|
|
114
|
+
step: { type: Number, default: DEFAULT_STEP },
|
|
115
|
+
value: { type: Number, default: DEFAULT_VALUE }
|
|
79
116
|
};
|
|
80
117
|
static actions = ["onKeydown", "onPointerDown", "toggle"];
|
|
81
118
|
static events = ["change"];
|
|
82
|
-
/**
|
|
83
|
-
#valueBeforeCollapse =
|
|
119
|
+
/** Where the divider sat before the current collapse; `null` when unknown. */
|
|
120
|
+
#valueBeforeCollapse = null;
|
|
84
121
|
/** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
|
|
85
122
|
#dragAbort = null;
|
|
86
123
|
/** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
|
|
87
124
|
#paneTabindex = new TabindexLoan();
|
|
88
125
|
/** Releases the pane cycle listener bound in {@link connect}. */
|
|
89
126
|
#cycleAbort = null;
|
|
127
|
+
/** Folds the range and position inputs of one morph batch into a single paint. */
|
|
128
|
+
#repaint = new MicrotaskCoalescer(() => this.#render());
|
|
90
129
|
connect() {
|
|
91
|
-
this
|
|
130
|
+
this.element.removeAttribute("data-dragging");
|
|
131
|
+
this.#repaint.activate();
|
|
132
|
+
this.#render();
|
|
92
133
|
this.#cycleAbort = new AbortController();
|
|
93
134
|
this.element.addEventListener("keydown", this.#onCycleKeydown, {
|
|
94
135
|
signal: this.#cycleAbort.signal
|
|
@@ -100,17 +141,19 @@ var ResizableController = class extends Controller {
|
|
|
100
141
|
this.#dragAbort = null;
|
|
101
142
|
this.#cycleAbort?.abort();
|
|
102
143
|
this.#cycleAbort = null;
|
|
144
|
+
this.#repaint.cancel();
|
|
103
145
|
this.#paneTabindex.returnAll();
|
|
146
|
+
this.element.removeAttribute("data-dragging");
|
|
104
147
|
}
|
|
105
148
|
/** Moves focus to the next pane on `F6`, wrapping; entering at the first one. */
|
|
106
149
|
#onCycleKeydown = (event) => {
|
|
107
150
|
if (event.key !== "F6") return;
|
|
151
|
+
if (event.isComposing) return;
|
|
108
152
|
if (event.defaultPrevented) return;
|
|
109
153
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
110
154
|
const panes = [];
|
|
111
155
|
if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
|
|
112
156
|
if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
|
|
113
|
-
if (panes.length === 0) return;
|
|
114
157
|
const target = event.target;
|
|
115
158
|
const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
|
|
116
159
|
const next = panes[(current + 1) % panes.length];
|
|
@@ -119,12 +162,21 @@ var ResizableController = class extends Controller {
|
|
|
119
162
|
this.#paneTabindex.lend(next);
|
|
120
163
|
next.focus();
|
|
121
164
|
};
|
|
122
|
-
/**
|
|
123
|
-
* Stimulus lifecycle callback when the valueValue changes.
|
|
124
|
-
* Keeps CSS fractions and ARIA status completely aligned.
|
|
125
|
-
*/
|
|
165
|
+
/** Re-renders when application code (or a Turbo morph) changes `value` at runtime. */
|
|
126
166
|
valueValueChanged() {
|
|
127
|
-
this.#
|
|
167
|
+
this.#repaint.schedule();
|
|
168
|
+
}
|
|
169
|
+
/** Re-renders when application code (or a Turbo morph) changes `min` at runtime. */
|
|
170
|
+
minValueChanged() {
|
|
171
|
+
this.#repaint.schedule();
|
|
172
|
+
}
|
|
173
|
+
/** Re-renders when application code (or a Turbo morph) changes `max` at runtime. */
|
|
174
|
+
maxValueChanged() {
|
|
175
|
+
this.#repaint.schedule();
|
|
176
|
+
}
|
|
177
|
+
/** Re-publishes range and position onto a separator swapped in after connect. */
|
|
178
|
+
separatorTargetConnected() {
|
|
179
|
+
this.#repaint.schedule();
|
|
128
180
|
}
|
|
129
181
|
/** Starts active pointer drag tracking and locks capture. */
|
|
130
182
|
onPointerDown(event) {
|
|
@@ -145,44 +197,44 @@ var ResizableController = class extends Controller {
|
|
|
145
197
|
onKeydown(event) {
|
|
146
198
|
if (isReservedArrowChord(event)) return;
|
|
147
199
|
if (!this.hasSeparatorTarget) return;
|
|
148
|
-
const
|
|
149
|
-
const isVertical =
|
|
200
|
+
const { min, max } = this.#range;
|
|
201
|
+
const isVertical = this.#isVertical;
|
|
150
202
|
let handled = true;
|
|
151
|
-
let nextValue = this
|
|
203
|
+
let nextValue = this.#position;
|
|
152
204
|
switch (event.key) {
|
|
153
205
|
case "ArrowLeft":
|
|
154
206
|
if (isVertical) {
|
|
155
|
-
nextValue -= this
|
|
207
|
+
nextValue -= this.#step;
|
|
156
208
|
} else {
|
|
157
209
|
handled = false;
|
|
158
210
|
}
|
|
159
211
|
break;
|
|
160
212
|
case "ArrowRight":
|
|
161
213
|
if (isVertical) {
|
|
162
|
-
nextValue += this
|
|
214
|
+
nextValue += this.#step;
|
|
163
215
|
} else {
|
|
164
216
|
handled = false;
|
|
165
217
|
}
|
|
166
218
|
break;
|
|
167
219
|
case "ArrowUp":
|
|
168
220
|
if (!isVertical) {
|
|
169
|
-
nextValue -= this
|
|
221
|
+
nextValue -= this.#step;
|
|
170
222
|
} else {
|
|
171
223
|
handled = false;
|
|
172
224
|
}
|
|
173
225
|
break;
|
|
174
226
|
case "ArrowDown":
|
|
175
227
|
if (!isVertical) {
|
|
176
|
-
nextValue += this
|
|
228
|
+
nextValue += this.#step;
|
|
177
229
|
} else {
|
|
178
230
|
handled = false;
|
|
179
231
|
}
|
|
180
232
|
break;
|
|
181
233
|
case "Home":
|
|
182
|
-
nextValue =
|
|
234
|
+
nextValue = min;
|
|
183
235
|
break;
|
|
184
236
|
case "End":
|
|
185
|
-
nextValue =
|
|
237
|
+
nextValue = max;
|
|
186
238
|
break;
|
|
187
239
|
case "Enter":
|
|
188
240
|
event.preventDefault();
|
|
@@ -194,65 +246,86 @@ var ResizableController = class extends Controller {
|
|
|
194
246
|
}
|
|
195
247
|
if (handled) {
|
|
196
248
|
event.preventDefault();
|
|
197
|
-
this.valueValue = Math.max(
|
|
198
|
-
this.#
|
|
249
|
+
this.valueValue = Math.max(min, Math.min(nextValue, max));
|
|
250
|
+
this.#render();
|
|
199
251
|
this.#dispatchChange();
|
|
200
252
|
}
|
|
201
253
|
}
|
|
202
|
-
/** Double-click or Enter to collapse
|
|
254
|
+
/** Double-click or Enter to collapse the primary pane, or put it back. */
|
|
203
255
|
toggle() {
|
|
204
|
-
const
|
|
205
|
-
if (this
|
|
206
|
-
this.#valueBeforeCollapse = this
|
|
207
|
-
this.valueValue =
|
|
256
|
+
const { min, max } = this.#range;
|
|
257
|
+
if (this.#position > min) {
|
|
258
|
+
this.#valueBeforeCollapse = this.#position;
|
|
259
|
+
this.valueValue = min;
|
|
208
260
|
} else {
|
|
209
|
-
this.valueValue = this.#valueBeforeCollapse
|
|
261
|
+
this.valueValue = this.#valueBeforeCollapse ?? max;
|
|
262
|
+
this.#valueBeforeCollapse = null;
|
|
210
263
|
}
|
|
211
|
-
this.#
|
|
264
|
+
this.#render();
|
|
212
265
|
this.#dispatchChange();
|
|
213
266
|
}
|
|
214
267
|
#onPointerMove = (event) => {
|
|
215
268
|
if (!this.hasSeparatorTarget) return;
|
|
216
269
|
const rect = this.element.getBoundingClientRect();
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
} else {
|
|
223
|
-
fraction = (event.clientY - rect.top) / rect.height;
|
|
224
|
-
}
|
|
225
|
-
fraction = Math.max(0, Math.min(fraction, 1));
|
|
226
|
-
const percent = Math.round(fraction * 100);
|
|
227
|
-
this.valueValue = Math.max(this.minValue, Math.min(percent, this.maxValue));
|
|
228
|
-
this.#clampAndSync();
|
|
270
|
+
const raw = this.#isVertical ? (event.clientX - rect.left) / rect.width : (event.clientY - rect.top) / rect.height;
|
|
271
|
+
const fraction = Number.isFinite(raw) ? Math.max(0, Math.min(raw, 1)) : 0;
|
|
272
|
+
const { min, max } = this.#range;
|
|
273
|
+
this.valueValue = Math.max(min, Math.min(Math.round(fraction * 100), max));
|
|
274
|
+
this.#render();
|
|
229
275
|
};
|
|
230
276
|
#onPointerUp = (event) => {
|
|
231
|
-
if (!this.hasSeparatorTarget) return;
|
|
232
|
-
const separator = this.separatorTarget;
|
|
233
|
-
separator.releasePointerCapture(event.pointerId);
|
|
234
277
|
this.element.removeAttribute("data-dragging");
|
|
235
278
|
this.#dragAbort?.abort();
|
|
236
279
|
this.#dragAbort = null;
|
|
280
|
+
if (this.hasSeparatorTarget) {
|
|
281
|
+
this.separatorTarget.releasePointerCapture(event.pointerId);
|
|
282
|
+
}
|
|
237
283
|
this.#dispatchChange();
|
|
238
284
|
};
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Publishes the position: the fraction consumer CSS multiplies a pane by, and
|
|
287
|
+
* the range assistive tech reads off the separator. The clamped position is
|
|
288
|
+
* written back to the Value so it, ARIA, CSS, and dispatched events agree.
|
|
289
|
+
*
|
|
290
|
+
* @stimeoRenderRoot
|
|
291
|
+
*/
|
|
292
|
+
#render() {
|
|
293
|
+
const { min, max } = this.#range;
|
|
294
|
+
const position = this.#position;
|
|
295
|
+
if (this.valueValue !== position) {
|
|
296
|
+
this.valueValue = position;
|
|
243
297
|
}
|
|
244
|
-
|
|
245
|
-
this.element.style.setProperty("--stimeo--resizable-fraction", String(fraction));
|
|
298
|
+
this.element.style.setProperty("--stimeo--resizable-fraction", String(position / 100));
|
|
246
299
|
if (this.hasSeparatorTarget) {
|
|
247
|
-
this.separatorTarget.setAttribute("aria-valuenow", String(
|
|
248
|
-
this.separatorTarget.setAttribute("aria-valuemin", String(
|
|
249
|
-
this.separatorTarget.setAttribute("aria-valuemax", String(
|
|
300
|
+
this.separatorTarget.setAttribute("aria-valuenow", String(position));
|
|
301
|
+
this.separatorTarget.setAttribute("aria-valuemin", String(min));
|
|
302
|
+
this.separatorTarget.setAttribute("aria-valuemax", String(max));
|
|
250
303
|
}
|
|
251
304
|
}
|
|
252
305
|
#dispatchChange() {
|
|
253
306
|
const fraction = this.valueValue / 100;
|
|
254
307
|
this.dispatch("change", { detail: { value: this.valueValue, fraction } });
|
|
255
308
|
}
|
|
309
|
+
/** The declared range after validation; an unreadable bound uses its default. */
|
|
310
|
+
get #range() {
|
|
311
|
+
const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN;
|
|
312
|
+
const max = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX;
|
|
313
|
+
return { min, max: Math.max(min, max) };
|
|
314
|
+
}
|
|
315
|
+
/** The declared position, clamped into the validated range. */
|
|
316
|
+
get #position() {
|
|
317
|
+
const { min, max } = this.#range;
|
|
318
|
+
const value = Number.isFinite(this.valueValue) ? this.valueValue : DEFAULT_VALUE;
|
|
319
|
+
return Math.max(min, Math.min(value, max));
|
|
320
|
+
}
|
|
321
|
+
/** The keyboard increment; a non-positive or unreadable one uses the default. */
|
|
322
|
+
get #step() {
|
|
323
|
+
return Number.isFinite(this.stepValue) && this.stepValue > 0 ? this.stepValue : DEFAULT_STEP;
|
|
324
|
+
}
|
|
325
|
+
/** Whether the divider runs vertically; a separator that does not say is horizontal. */
|
|
326
|
+
get #isVertical() {
|
|
327
|
+
return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
|
|
328
|
+
}
|
|
256
329
|
};
|
|
257
330
|
|
|
258
331
|
export { ResizableController };
|
|
@@ -222,7 +222,7 @@ var SpinnerController = class extends Controller {
|
|
|
222
222
|
timeout: { type: Number, default: 0 }
|
|
223
223
|
};
|
|
224
224
|
static actions = ["start", "stop"];
|
|
225
|
-
static events = ["hide", "show", "timeout"];
|
|
225
|
+
static events = ["hide", "show", "timeout", "reconcile"];
|
|
226
226
|
#timers = new SafeTimeout();
|
|
227
227
|
#floor = new MinDurationFloor(this.#timers);
|
|
228
228
|
#gate = new DetachGate();
|
|
@@ -326,16 +326,18 @@ var SpinnerController = class extends Controller {
|
|
|
326
326
|
* Returns the loading state to idle for the snapshot Turbo is about to take,
|
|
327
327
|
* so a page reached with the Back button is not restored mid-load with a
|
|
328
328
|
* spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
|
|
329
|
-
* and `aria-busy`. No `hide` is dispatched —
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
* leaves the running cycle intact.
|
|
329
|
+
* and `aria-busy`. No `hide` is dispatched — that would claim the load finished.
|
|
330
|
+
* The rewind reports itself as `reconcile` instead, so a consumer painting from
|
|
331
|
+
* `show` can drop it before the snapshot is taken. The live page keeps its
|
|
332
|
+
* timers, so a navigation that never completes leaves the running cycle intact.
|
|
333
333
|
*/
|
|
334
334
|
#rewindForCache() {
|
|
335
|
+
const running = this.element.getAttribute("data-state") !== "idle";
|
|
335
336
|
this.#cancelTimeout();
|
|
336
337
|
this.#setBusy(false);
|
|
337
338
|
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
|
|
338
339
|
this.element.setAttribute("data-state", "idle");
|
|
340
|
+
if (running) this.dispatch("reconcile", { detail: {} });
|
|
339
341
|
}
|
|
340
342
|
/** Reflects busy state onto the controlled region (if present). */
|
|
341
343
|
#setBusy(busy) {
|
|
@@ -203,7 +203,7 @@ var SubmitOnceController = class extends Controller {
|
|
|
203
203
|
restoreFocus: { type: Boolean, default: false }
|
|
204
204
|
};
|
|
205
205
|
static actions = ["cancel", "finish", "start"];
|
|
206
|
-
static events = ["start", "end"];
|
|
206
|
+
static events = ["start", "end", "reconcile"];
|
|
207
207
|
#timers = new SafeTimeout();
|
|
208
208
|
#gate = new DetachGate();
|
|
209
209
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
@@ -419,7 +419,9 @@ var SubmitOnceController = class extends Controller {
|
|
|
419
419
|
}
|
|
420
420
|
/** Keeps Turbo's cached snapshot idle, without events, announcements, or focus moves. */
|
|
421
421
|
#rewindForCache() {
|
|
422
|
+
const forms = [...this.#sessions.keys()];
|
|
422
423
|
this.#abandonSessions();
|
|
424
|
+
if (forms.length > 0) this.dispatch("reconcile", { detail: { forms } });
|
|
423
425
|
}
|
|
424
426
|
/** Tears down a true detach; an in-page move is canceled by the next `connect()`. */
|
|
425
427
|
#teardown() {
|
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/textarea_autosize_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/layout_observer.ts
|
|
6
|
+
var LayoutObserver = class {
|
|
7
|
+
#callback;
|
|
8
|
+
#resizeObserverFactory;
|
|
9
|
+
#resizeObserver = null;
|
|
10
|
+
#observingViewport = false;
|
|
11
|
+
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
12
|
+
#handleViewportResize = () => {
|
|
13
|
+
this.#callback();
|
|
14
|
+
};
|
|
15
|
+
constructor(callback, options = {}) {
|
|
16
|
+
this.#callback = callback;
|
|
17
|
+
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Starts observing an element's size. Repeated calls observe additional
|
|
21
|
+
* elements through the same shared observer. No-ops when no
|
|
22
|
+
* `ResizeObserver` implementation is available.
|
|
23
|
+
*/
|
|
24
|
+
observe(element) {
|
|
25
|
+
if (!this.#resizeObserverFactory) return;
|
|
26
|
+
if (!this.#resizeObserver) {
|
|
27
|
+
this.#resizeObserver = this.#resizeObserverFactory(() => {
|
|
28
|
+
this.#callback();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
this.#resizeObserver.observe(element);
|
|
32
|
+
}
|
|
33
|
+
/** Stops observing a single element while leaving any others in place. */
|
|
34
|
+
unobserve(element) {
|
|
35
|
+
this.#resizeObserver?.unobserve(element);
|
|
36
|
+
}
|
|
37
|
+
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
38
|
+
observeViewport() {
|
|
39
|
+
if (this.#observingViewport) return;
|
|
40
|
+
this.#observingViewport = true;
|
|
41
|
+
window.addEventListener("resize", this.#handleViewportResize);
|
|
42
|
+
}
|
|
43
|
+
/** Stops observing viewport resizes without affecting element observation. */
|
|
44
|
+
unobserveViewport() {
|
|
45
|
+
if (!this.#observingViewport) return;
|
|
46
|
+
this.#observingViewport = false;
|
|
47
|
+
window.removeEventListener("resize", this.#handleViewportResize);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and
|
|
51
|
+
* removes the viewport listener. Safe to call multiple times. Call this from a
|
|
52
|
+
* controller's `disconnect()`.
|
|
53
|
+
*/
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#resizeObserver?.disconnect();
|
|
56
|
+
this.#resizeObserver = null;
|
|
57
|
+
this.unobserveViewport();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
3
61
|
// src/controllers/textarea_autosize_controller.ts
|
|
4
62
|
function px(value) {
|
|
5
63
|
const n = Number.parseFloat(value);
|
|
@@ -13,18 +71,69 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
13
71
|
static actions = ["resize"];
|
|
14
72
|
static events = ["resize"];
|
|
15
73
|
#lastHeight = -1;
|
|
16
|
-
#
|
|
74
|
+
#lastWidth = -1;
|
|
75
|
+
#started = false;
|
|
76
|
+
#pending = false;
|
|
77
|
+
#fonts = null;
|
|
78
|
+
/**
|
|
79
|
+
* Content triggers arriving while the box is collapsed (`display: none`
|
|
80
|
+
* ancestors) are deferred: `scrollHeight` is 0 there, so measuring would
|
|
81
|
+
* clamp the kept height to the `minRows` floor and dispatch a bogus
|
|
82
|
+
* `resize`. The next report from a rendered box flushes the deferral.
|
|
83
|
+
*/
|
|
84
|
+
#remeasure = () => {
|
|
85
|
+
if (this.element.clientWidth === 0) {
|
|
86
|
+
this.#pending = true;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
17
89
|
this.resize();
|
|
18
90
|
};
|
|
91
|
+
/**
|
|
92
|
+
* Re-measures when the content width changed (re-wrapping, including a
|
|
93
|
+
* horizontal padding change under `border-box`), the applied inline height
|
|
94
|
+
* was stripped (a Turbo morph syncing attributes from server HTML that has no
|
|
95
|
+
* `style`), or a content trigger was deferred while the box was collapsed.
|
|
96
|
+
* This instance's own height writes match none of these signals, so they
|
|
97
|
+
* cannot re-enter; an authored inline height (e.g. a user dragging the native
|
|
98
|
+
* resize handle) is left alone until the next content trigger. Reports from a
|
|
99
|
+
* still-collapsed box are skipped (nothing is measurable there).
|
|
100
|
+
*/
|
|
101
|
+
#layout = new LayoutObserver(() => {
|
|
102
|
+
if (this.element.clientWidth === 0) return;
|
|
103
|
+
const width = this.#contentWidth();
|
|
104
|
+
const stripped = this.element.style.height === "";
|
|
105
|
+
if (width === this.#lastWidth && !stripped && !this.#pending) return;
|
|
106
|
+
this.#lastWidth = width;
|
|
107
|
+
this.resize();
|
|
108
|
+
});
|
|
19
109
|
connect() {
|
|
20
|
-
this.element.
|
|
110
|
+
this.#lastHeight = Math.round(px(this.element.style.height));
|
|
111
|
+
this.#lastWidth = this.#contentWidth();
|
|
112
|
+
this.element.addEventListener("input", this.#remeasure);
|
|
113
|
+
this.element.addEventListener("change", this.#remeasure);
|
|
114
|
+
this.#layout.observe(this.element);
|
|
115
|
+
this.#bindFonts();
|
|
116
|
+
this.#started = true;
|
|
21
117
|
this.resize();
|
|
22
118
|
}
|
|
23
119
|
disconnect() {
|
|
24
|
-
this
|
|
120
|
+
this.#started = false;
|
|
121
|
+
this.element.removeEventListener("input", this.#remeasure);
|
|
122
|
+
this.element.removeEventListener("change", this.#remeasure);
|
|
123
|
+
this.#layout.disconnect();
|
|
124
|
+
this.#unbindFonts();
|
|
125
|
+
}
|
|
126
|
+
/** Re-clamps when application code or a Turbo morph changes `minRows`. */
|
|
127
|
+
minRowsValueChanged() {
|
|
128
|
+
if (this.#started) this.#remeasure();
|
|
129
|
+
}
|
|
130
|
+
/** Re-clamps when application code or a Turbo morph changes `maxRows`. */
|
|
131
|
+
maxRowsValueChanged() {
|
|
132
|
+
if (this.#started) this.#remeasure();
|
|
25
133
|
}
|
|
26
134
|
/** Re-measures the content and applies the clamped height. */
|
|
27
135
|
resize() {
|
|
136
|
+
this.#pending = false;
|
|
28
137
|
const el = this.element;
|
|
29
138
|
const style = window.getComputedStyle(el);
|
|
30
139
|
const lineHeight = this.#lineHeight(style);
|
|
@@ -58,6 +167,11 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
58
167
|
this.dispatch("resize", { detail: { height, rows } });
|
|
59
168
|
}
|
|
60
169
|
}
|
|
170
|
+
/** Content-box width — the wrapping input, unaffected by this instance's height writes. */
|
|
171
|
+
#contentWidth() {
|
|
172
|
+
const style = window.getComputedStyle(this.element);
|
|
173
|
+
return Math.max(0, this.element.clientWidth - px(style.paddingLeft) - px(style.paddingRight));
|
|
174
|
+
}
|
|
61
175
|
/** Resolved line height, falling back to ~1.2× font-size when `normal`. */
|
|
62
176
|
#lineHeight(style) {
|
|
63
177
|
const lh = px(style.lineHeight);
|
|
@@ -65,6 +179,20 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
65
179
|
const fontSize = px(style.fontSize);
|
|
66
180
|
return fontSize > 0 ? fontSize * 1.2 : 16;
|
|
67
181
|
}
|
|
182
|
+
/** Subscribes to font completion when the current engine exposes `document.fonts`. */
|
|
183
|
+
#bindFonts() {
|
|
184
|
+
const fonts = this.element.ownerDocument.fonts;
|
|
185
|
+
if (!fonts) return;
|
|
186
|
+
this.#fonts = fonts;
|
|
187
|
+
fonts.addEventListener("loadingdone", this.#remeasure);
|
|
188
|
+
fonts.addEventListener("loadingerror", this.#remeasure);
|
|
189
|
+
}
|
|
190
|
+
/** Removes the font completion subscriptions. */
|
|
191
|
+
#unbindFonts() {
|
|
192
|
+
this.#fonts?.removeEventListener("loadingdone", this.#remeasure);
|
|
193
|
+
this.#fonts?.removeEventListener("loadingerror", this.#remeasure);
|
|
194
|
+
this.#fonts = null;
|
|
195
|
+
}
|
|
68
196
|
};
|
|
69
197
|
|
|
70
198
|
export { TextareaAutosizeController };
|