stimeo-ui 0.6.0 → 0.7.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 +62 -0
- data/dist/controllers/alert_dialog_controller.js +75 -4
- data/dist/controllers/avatar_controller.js +47 -5
- data/dist/controllers/character_counter_controller.js +338 -63
- data/dist/controllers/command_palette_controller.js +75 -4
- data/dist/controllers/conditional_fields_controller.js +345 -51
- data/dist/controllers/confirm_controller.js +75 -4
- data/dist/controllers/dialog_controller.js +75 -4
- data/dist/controllers/direct_upload_controller.js +201 -45
- data/dist/controllers/dirty_form_controller.js +192 -29
- data/dist/controllers/dismissible_controller.js +83 -18
- data/dist/controllers/drawer_controller.js +75 -4
- data/dist/controllers/focus_controller.js +75 -4
- data/dist/controllers/form_field_controller.js +280 -62
- data/dist/controllers/form_validation_controller.js +208 -83
- data/dist/controllers/number_input_controller.js +47 -5
- data/dist/controllers/overflow_menu_controller.js +2 -1
- data/dist/controllers/pagination_controller.js +2 -1
- data/dist/controllers/persist_controller.js +432 -122
- data/dist/controllers/popover_controller.js +77 -4
- data/dist/controllers/rating_controller.js +9 -5
- data/dist/controllers/scroll_area_controller.js +462 -162
- data/dist/controllers/separator_controller.js +354 -38
- data/dist/controllers/sidebar_controller.js +83 -10
- data/dist/controllers/submit_once_controller.js +399 -121
- data/dist/controllers/theme_controller.js +8 -6
- data/dist/controllers/tree_view_controller.js +2 -1
- data/dist/index.js +2387 -861
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -9,8 +9,80 @@ function setDefaultAttribute(element, name, value) {
|
|
|
9
9
|
return true;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
// src/utils/
|
|
13
|
-
|
|
12
|
+
// src/utils/focus_candidate.ts
|
|
13
|
+
function inheritsFieldsetDisabled(control) {
|
|
14
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
15
|
+
while (fieldset) {
|
|
16
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
17
|
+
if (!legend?.contains(control)) return true;
|
|
18
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function canTakeFocus(element) {
|
|
23
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
24
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
25
|
+
if (!("disabled" in element)) return true;
|
|
26
|
+
if (element.disabled) return false;
|
|
27
|
+
return !inheritsFieldsetDisabled(element);
|
|
28
|
+
}
|
|
29
|
+
var TAB_STOP_CANDIDATE_SELECTOR = [
|
|
30
|
+
"a[href]",
|
|
31
|
+
"area[href]",
|
|
32
|
+
"button",
|
|
33
|
+
"input",
|
|
34
|
+
"select",
|
|
35
|
+
"textarea",
|
|
36
|
+
"summary",
|
|
37
|
+
"iframe",
|
|
38
|
+
"audio[controls]",
|
|
39
|
+
"video[controls]",
|
|
40
|
+
"[tabindex]",
|
|
41
|
+
"[contenteditable]"
|
|
42
|
+
].join(",");
|
|
43
|
+
function isRenderedForFocus(element) {
|
|
44
|
+
const check = element.checkVisibility;
|
|
45
|
+
return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
|
|
46
|
+
}
|
|
47
|
+
function authoredTabindex(element) {
|
|
48
|
+
const value = element.getAttribute("tabindex");
|
|
49
|
+
if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
|
|
50
|
+
return Number(value);
|
|
51
|
+
}
|
|
52
|
+
function hasNativeTabStop(element) {
|
|
53
|
+
if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
|
|
54
|
+
return element.hasAttribute("href");
|
|
55
|
+
}
|
|
56
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (element instanceof HTMLInputElement) return element.type !== "hidden";
|
|
60
|
+
if (element instanceof HTMLIFrameElement) return true;
|
|
61
|
+
if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
|
|
62
|
+
return element.hasAttribute("controls");
|
|
63
|
+
}
|
|
64
|
+
if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
|
|
65
|
+
const details = element.parentElement;
|
|
66
|
+
return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
function hasEditableTabStop(element) {
|
|
71
|
+
const value = element.getAttribute("contenteditable")?.toLowerCase();
|
|
72
|
+
return value === "" || value === "true" || value === "plaintext-only";
|
|
73
|
+
}
|
|
74
|
+
function isTabStop(element) {
|
|
75
|
+
if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
|
|
76
|
+
const tabindex = authoredTabindex(element);
|
|
77
|
+
if (tabindex !== null) return tabindex >= 0;
|
|
78
|
+
return hasNativeTabStop(element) || hasEditableTabStop(element);
|
|
79
|
+
}
|
|
80
|
+
function firstTabStop(root) {
|
|
81
|
+
for (const candidate of root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)) {
|
|
82
|
+
if (isTabStop(candidate)) return candidate;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
14
86
|
|
|
15
87
|
// src/controllers/form_validation_controller.ts
|
|
16
88
|
var CONSTRAINT_MESSAGE_KEYS = [
|
|
@@ -24,10 +96,12 @@ var CONSTRAINT_MESSAGE_KEYS = [
|
|
|
24
96
|
["stepMismatch", "step-mismatch"],
|
|
25
97
|
["badInput", "bad-input"]
|
|
26
98
|
];
|
|
27
|
-
var MESSAGE_ATTR_PREFIX = "data-stimeo--form-
|
|
28
|
-
var MESSAGE_ATTR_GENERIC = "data-stimeo--form-
|
|
29
|
-
var DISALLOW_ATTR = "data-stimeo--form-
|
|
99
|
+
var MESSAGE_ATTR_PREFIX = "data-stimeo--form-validation-message-";
|
|
100
|
+
var MESSAGE_ATTR_GENERIC = "data-stimeo--form-validation-message";
|
|
101
|
+
var DISALLOW_ATTR = "data-stimeo--form-validation-disallow";
|
|
102
|
+
var DISALLOW_WHITESPACE_MESSAGE = `${MESSAGE_ATTR_PREFIX}whitespace`;
|
|
30
103
|
var DISALLOW_WHITESPACE_DEFAULT = "Please enter a value that is not only whitespace.";
|
|
104
|
+
var FORM_FIELD_INVALID_ATTR = "data-stimeo--form-field-invalid";
|
|
31
105
|
var FormValidationController = class _FormValidationController extends Controller {
|
|
32
106
|
static outlets = ["stimeo--form-field"];
|
|
33
107
|
static values = {
|
|
@@ -40,14 +114,21 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
40
114
|
static events = ["valid", "invalid"];
|
|
41
115
|
/** Marker recording that we added `novalidate`, so we only remove our own. */
|
|
42
116
|
static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
|
|
43
|
-
/**
|
|
44
|
-
#
|
|
117
|
+
/** Object-backed groups already interacted with — the input revalidation gate. */
|
|
118
|
+
#touchedGroups = /* @__PURE__ */ new WeakSet();
|
|
119
|
+
/** String-backed fallback radio groups already interacted with. */
|
|
120
|
+
#touchedRadioGroups = /* @__PURE__ */ new Set();
|
|
45
121
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* the
|
|
122
|
+
* Last message this controller wrote through `setCustomValidity`, kept as a
|
|
123
|
+
* two-layer ownership ledger: a value is cleared only while the live message
|
|
124
|
+
* still equals the recorded write. Iterable so disconnect can release every
|
|
125
|
+
* surviving loan without touching a consumer's later custom error.
|
|
49
126
|
*/
|
|
50
|
-
#
|
|
127
|
+
#ownedCustomErrors = /* @__PURE__ */ new Map();
|
|
128
|
+
/** Last invalid message routed to each field, suppressing duplicate reports. */
|
|
129
|
+
#reportedErrors = /* @__PURE__ */ new WeakMap();
|
|
130
|
+
/** Exact document that owns the delegated listeners for this connection. */
|
|
131
|
+
#listenerDocument = null;
|
|
51
132
|
#onSubmit = (event) => {
|
|
52
133
|
if (event.target !== this.element) return;
|
|
53
134
|
const invalid = this.#validateAll();
|
|
@@ -63,43 +144,51 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
63
144
|
};
|
|
64
145
|
#onFocusOut = (event) => {
|
|
65
146
|
if (!this.validateOnBlurValue) return;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
147
|
+
const snapshot = this.#snapshot();
|
|
148
|
+
const group = snapshot.groupByControl.get(this.#controlFrom(event.target));
|
|
149
|
+
if (!group) return;
|
|
69
150
|
const related = event.relatedTarget;
|
|
70
|
-
if (field && related instanceof Node && field.element.contains(related)) return;
|
|
71
|
-
this.#
|
|
72
|
-
this.#
|
|
151
|
+
if (group.field && related instanceof Node && group.field.element.contains(related)) return;
|
|
152
|
+
if (snapshot.groupByControl.get(this.#controlFrom(related))?.key === group.key) return;
|
|
153
|
+
this.#markTouched(group.key);
|
|
154
|
+
this.#applyGroup(group);
|
|
73
155
|
};
|
|
74
156
|
#onInput = (event) => {
|
|
75
157
|
if (!this.revalidateOnInputValue) return;
|
|
76
|
-
const
|
|
77
|
-
if (!
|
|
78
|
-
this.#
|
|
158
|
+
const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));
|
|
159
|
+
if (!group || !this.#isTouched(group.key)) return;
|
|
160
|
+
this.#applyGroup(group);
|
|
79
161
|
};
|
|
80
162
|
#onChange = (event) => {
|
|
81
163
|
if (!this.validateOnChangeValue) return;
|
|
82
|
-
const
|
|
83
|
-
if (!
|
|
84
|
-
this.#
|
|
85
|
-
this.#
|
|
164
|
+
const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));
|
|
165
|
+
if (!group) return;
|
|
166
|
+
this.#markTouched(group.key);
|
|
167
|
+
this.#applyGroup(group);
|
|
86
168
|
};
|
|
87
169
|
/** Suppresses native bubbles and binds the submit / blur / input listeners. */
|
|
88
170
|
connect() {
|
|
89
171
|
if (setDefaultAttribute(this.element, "novalidate", "")) {
|
|
90
172
|
this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
|
|
91
173
|
}
|
|
92
|
-
|
|
93
|
-
this.
|
|
94
|
-
this.
|
|
95
|
-
this.
|
|
174
|
+
this.#listenerDocument = this.element.ownerDocument;
|
|
175
|
+
this.#listenerDocument.addEventListener("submit", this.#onSubmit, true);
|
|
176
|
+
this.#listenerDocument.addEventListener("focusout", this.#onFocusOut);
|
|
177
|
+
this.#listenerDocument.addEventListener("input", this.#onInput);
|
|
178
|
+
this.#listenerDocument.addEventListener("change", this.#onChange);
|
|
96
179
|
}
|
|
97
180
|
/** Tears down listeners and restores `novalidate` if we added it. */
|
|
98
181
|
disconnect() {
|
|
99
|
-
|
|
100
|
-
this
|
|
101
|
-
this
|
|
102
|
-
this
|
|
182
|
+
this.#listenerDocument?.removeEventListener("submit", this.#onSubmit, true);
|
|
183
|
+
this.#listenerDocument?.removeEventListener("focusout", this.#onFocusOut);
|
|
184
|
+
this.#listenerDocument?.removeEventListener("input", this.#onInput);
|
|
185
|
+
this.#listenerDocument?.removeEventListener("change", this.#onChange);
|
|
186
|
+
this.#listenerDocument = null;
|
|
187
|
+
for (const [control, message] of this.#ownedCustomErrors) {
|
|
188
|
+
if (control.validationMessage === message) control.setCustomValidity("");
|
|
189
|
+
}
|
|
190
|
+
this.#ownedCustomErrors.clear();
|
|
191
|
+
this.#touchedRadioGroups.clear();
|
|
103
192
|
if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
|
|
104
193
|
this.element.removeAttribute("novalidate");
|
|
105
194
|
this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
|
|
@@ -107,8 +196,8 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
107
196
|
}
|
|
108
197
|
/**
|
|
109
198
|
* Validates every control now, rendering or clearing each field's message, and
|
|
110
|
-
* returns whether the whole form is valid. Marks every
|
|
111
|
-
* later input re-validates it. Bound via `data-action`
|
|
199
|
+
* returns whether the whole form is valid. Marks every field/group touched so
|
|
200
|
+
* a later input from any sibling re-validates it. Bound via `data-action`
|
|
112
201
|
* (`#validate`) or callable directly (e.g. before a programmatic submit).
|
|
113
202
|
*/
|
|
114
203
|
validate() {
|
|
@@ -122,33 +211,42 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
122
211
|
* Each group's first invalid control supplies the message and the focus target.
|
|
123
212
|
*/
|
|
124
213
|
#validateAll() {
|
|
214
|
+
const invalid = [];
|
|
215
|
+
for (const group of this.#snapshot().groups.values()) {
|
|
216
|
+
this.#markTouched(group.key);
|
|
217
|
+
const firstInvalid = this.#applyGroup(group);
|
|
218
|
+
if (firstInvalid) invalid.push(firstInvalid);
|
|
219
|
+
}
|
|
220
|
+
return invalid;
|
|
221
|
+
}
|
|
222
|
+
/** Builds the current field table in one pass over form controls and DOM depth. */
|
|
223
|
+
#snapshot() {
|
|
224
|
+
const fields = this.#fieldOutlets();
|
|
125
225
|
const groups = /* @__PURE__ */ new Map();
|
|
226
|
+
const groupByControl = /* @__PURE__ */ new Map();
|
|
126
227
|
for (const control of this.#controls) {
|
|
127
|
-
this.#
|
|
128
|
-
const field = this.#fieldFor(control);
|
|
228
|
+
const field = this.#fieldFor(control, fields);
|
|
129
229
|
const key = this.#keyFor(control, field);
|
|
130
|
-
|
|
131
|
-
if (group) {
|
|
132
|
-
group
|
|
133
|
-
|
|
134
|
-
groups.set(key, { field, controls: [control] });
|
|
230
|
+
let group = groups.get(key);
|
|
231
|
+
if (!group) {
|
|
232
|
+
group = { key, field, controls: [] };
|
|
233
|
+
groups.set(key, group);
|
|
135
234
|
}
|
|
235
|
+
group.controls.push(control);
|
|
236
|
+
groupByControl.set(control, group);
|
|
136
237
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
238
|
+
return { groups, groupByControl };
|
|
239
|
+
}
|
|
240
|
+
/** Records that a whole field/group, rather than one sibling control, was visited. */
|
|
241
|
+
#markTouched(key) {
|
|
242
|
+
if (typeof key === "string") {
|
|
243
|
+
this.#touchedRadioGroups.add(key);
|
|
244
|
+
} else {
|
|
245
|
+
this.#touchedGroups.add(key);
|
|
141
246
|
}
|
|
142
|
-
return invalid;
|
|
143
247
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const field = this.#fieldFor(control);
|
|
147
|
-
const key = this.#keyFor(control, field);
|
|
148
|
-
const controls = this.#controls.filter(
|
|
149
|
-
(other) => this.#keyFor(other, this.#fieldFor(other)) === key
|
|
150
|
-
);
|
|
151
|
-
this.#applyGroup({ field, controls });
|
|
248
|
+
#isTouched(key) {
|
|
249
|
+
return typeof key === "string" ? this.#touchedRadioGroups.has(key) : this.#touchedGroups.has(key);
|
|
152
250
|
}
|
|
153
251
|
/**
|
|
154
252
|
* Runs native constraint validation across a field's controls and routes the
|
|
@@ -162,51 +260,63 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
162
260
|
const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
|
|
163
261
|
if (group.field) {
|
|
164
262
|
if (firstInvalid) {
|
|
165
|
-
|
|
166
|
-
|
|
263
|
+
const message = this.#messageFor(firstInvalid);
|
|
264
|
+
const alreadyReported = group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) && this.#reportedErrors.get(group.field) === message;
|
|
265
|
+
if (!alreadyReported) group.field.setError(message, { focus: false });
|
|
266
|
+
this.#reportedErrors.set(group.field, message);
|
|
267
|
+
} else if (group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) || this.#reportedErrors.has(group.field)) {
|
|
167
268
|
group.field.clearError();
|
|
269
|
+
this.#reportedErrors.delete(group.field);
|
|
168
270
|
}
|
|
169
271
|
}
|
|
170
272
|
return firstInvalid;
|
|
171
273
|
}
|
|
172
274
|
/**
|
|
173
275
|
* Resolves the message to show for an invalid control: a per-constraint
|
|
174
|
-
* override (`data-stimeo--form-
|
|
175
|
-
* `ValidityState` flag, then a generic `data-stimeo--form-
|
|
276
|
+
* override (`data-stimeo--form-validation-message-<constraint>`) for the first failing
|
|
277
|
+
* `ValidityState` flag, then a generic `data-stimeo--form-validation-message`
|
|
176
278
|
* override, then the browser's native `validationMessage`. Authoring an override
|
|
177
279
|
* gives controlled, localizable, theme-able wording with **no consumer JS** —
|
|
178
280
|
* and sidesteps headless browsers that return an empty native message.
|
|
179
281
|
*/
|
|
180
282
|
#messageFor(control) {
|
|
283
|
+
if (control.validity.customError) return control.validationMessage;
|
|
181
284
|
for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {
|
|
182
285
|
if (control.validity[flag]) {
|
|
183
|
-
return control
|
|
286
|
+
return this.#authoredMessage(control, `${MESSAGE_ATTR_PREFIX}${key}`) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
|
|
184
287
|
}
|
|
185
288
|
}
|
|
186
|
-
return control.validationMessage || control
|
|
289
|
+
return control.validationMessage || this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) || "";
|
|
187
290
|
}
|
|
188
291
|
/**
|
|
189
292
|
* Applies (or clears) a declarative custom constraint via `setCustomValidity`,
|
|
190
|
-
* for controls that opt in with `data-stimeo--form-
|
|
293
|
+
* for controls that opt in with `data-stimeo--form-validation-disallow`. The supported
|
|
191
294
|
* rule is `"whitespace"` — a value that is non-empty but blank
|
|
192
295
|
* after trimming (which slips past `required` / `minlength`); its message follows
|
|
193
|
-
* the
|
|
296
|
+
* the whitespace-specific → generic → default chain.
|
|
194
297
|
*
|
|
195
|
-
* Don't-clobber-authored-state: an unknown/absent rule is never
|
|
196
|
-
* custom error
|
|
197
|
-
*
|
|
298
|
+
* Don't-clobber-authored-state: an unknown/absent rule is never written, an
|
|
299
|
+
* existing consumer custom error wins, and a controller error is only cleared
|
|
300
|
+
* while the live message still equals the recorded controller write.
|
|
198
301
|
*/
|
|
199
302
|
#syncCustomValidity(control) {
|
|
303
|
+
const recorded = this.#ownedCustomErrors.get(control);
|
|
304
|
+
const ownsCurrent = recorded !== void 0 && control.validationMessage === recorded;
|
|
200
305
|
const violates = control.getAttribute(DISALLOW_ATTR) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
|
|
201
|
-
if (violates) {
|
|
202
|
-
control.setCustomValidity(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this.#ownedCustomError.add(control);
|
|
206
|
-
} else if (this.#ownedCustomError.has(control)) {
|
|
207
|
-
this.#ownedCustomError.delete(control);
|
|
208
|
-
control.setCustomValidity("");
|
|
306
|
+
if (!violates) {
|
|
307
|
+
if (ownsCurrent) control.setCustomValidity("");
|
|
308
|
+
this.#ownedCustomErrors.delete(control);
|
|
309
|
+
return;
|
|
209
310
|
}
|
|
311
|
+
if (control.validity.customError && !ownsCurrent) return;
|
|
312
|
+
const message = this.#authoredMessage(control, DISALLOW_WHITESPACE_MESSAGE) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT;
|
|
313
|
+
if (!ownsCurrent || recorded !== message) control.setCustomValidity(message);
|
|
314
|
+
this.#ownedCustomErrors.set(control, message);
|
|
315
|
+
}
|
|
316
|
+
/** Returns a non-blank authored message, otherwise falls through to a fallback. */
|
|
317
|
+
#authoredMessage(control, attribute) {
|
|
318
|
+
const message = control.getAttribute(attribute);
|
|
319
|
+
return message && message.trim().length > 0 ? message : null;
|
|
210
320
|
}
|
|
211
321
|
/**
|
|
212
322
|
* A grouping key that collects controls belonging to the same field: the owning
|
|
@@ -230,18 +340,32 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
230
340
|
* is deterministic and CSS-independent.
|
|
231
341
|
*/
|
|
232
342
|
#focusTargetFor(control) {
|
|
233
|
-
if (
|
|
234
|
-
const field = this.#fieldFor(control);
|
|
343
|
+
if (canTakeFocus(control)) return control;
|
|
344
|
+
const field = this.#fieldFor(control, this.#fieldOutlets());
|
|
235
345
|
if (!field?.hasControlTarget) return null;
|
|
236
346
|
const root = field.controlTarget;
|
|
237
|
-
if (root
|
|
238
|
-
return root
|
|
347
|
+
if (isTabStop(root)) return root;
|
|
348
|
+
return firstTabStop(root);
|
|
239
349
|
}
|
|
240
|
-
/**
|
|
241
|
-
#
|
|
350
|
+
/** Pairs each live outlet element with its controller once per operation. */
|
|
351
|
+
#fieldOutlets() {
|
|
352
|
+
const fields = /* @__PURE__ */ new Map();
|
|
242
353
|
const elements = this.stimeoFormFieldOutletElements;
|
|
354
|
+
const outlets = this.stimeoFormFieldOutlets;
|
|
243
355
|
for (let index = 0; index < elements.length; index++) {
|
|
244
|
-
|
|
356
|
+
const element = elements[index];
|
|
357
|
+
const outlet = outlets[index];
|
|
358
|
+
if (element && outlet) fields.set(element, outlet);
|
|
359
|
+
}
|
|
360
|
+
return fields;
|
|
361
|
+
}
|
|
362
|
+
/** The nearest configured field ancestor of `control`, if any. */
|
|
363
|
+
#fieldFor(control, fields) {
|
|
364
|
+
let ancestor = control;
|
|
365
|
+
while (ancestor) {
|
|
366
|
+
const field = fields.get(ancestor);
|
|
367
|
+
if (field) return field;
|
|
368
|
+
ancestor = ancestor.parentElement;
|
|
245
369
|
}
|
|
246
370
|
return void 0;
|
|
247
371
|
}
|
|
@@ -255,11 +379,12 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
255
379
|
}
|
|
256
380
|
/** Narrows an event target to a validatable control. */
|
|
257
381
|
#controlFrom(target) {
|
|
258
|
-
return target instanceof Element && this.#isValidatable(target) ? target : null;
|
|
382
|
+
return target instanceof Element && this.#isValidatable(target) && target.form === this.element ? target : null;
|
|
259
383
|
}
|
|
260
384
|
#isValidatable(element) {
|
|
261
|
-
return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, hidden,
|
|
262
|
-
//
|
|
385
|
+
return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, input[type=hidden],
|
|
386
|
+
// and button-type controls. The `hidden` attribute intentionally does not
|
|
387
|
+
// bar a text mirror from constraint validation.
|
|
263
388
|
element.willValidate;
|
|
264
389
|
}
|
|
265
390
|
};
|
|
@@ -27,17 +27,21 @@ var AttributeLease = class {
|
|
|
27
27
|
written: value
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
else element.setAttribute(this.#attribute, value);
|
|
30
|
+
this.#reflect(element, value);
|
|
32
31
|
}
|
|
33
32
|
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
34
33
|
return(element) {
|
|
35
34
|
const record = this.#records.get(element);
|
|
36
35
|
if (!record) return;
|
|
37
36
|
this.#records.delete(element);
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
37
|
+
const stillOwned = element.getAttribute(this.#attribute) === record.written;
|
|
38
|
+
if (stillOwned) this.#reflect(element, record.original);
|
|
39
|
+
}
|
|
40
|
+
/** Reflects only a real value transition, avoiding self-triggered mutation work. */
|
|
41
|
+
#reflect(element, value) {
|
|
42
|
+
if (element.getAttribute(this.#attribute) === value) return;
|
|
43
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
44
|
+
else element.setAttribute(this.#attribute, value);
|
|
41
45
|
}
|
|
42
46
|
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
43
47
|
returnAll() {
|
|
@@ -45,6 +49,35 @@ var AttributeLease = class {
|
|
|
45
49
|
}
|
|
46
50
|
};
|
|
47
51
|
|
|
52
|
+
// src/utils/before_cache_reset.ts
|
|
53
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
54
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
55
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
56
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
57
|
+
static #onBeforeCache = () => {
|
|
58
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
59
|
+
};
|
|
60
|
+
#rewind;
|
|
61
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
62
|
+
constructor(rewind) {
|
|
63
|
+
this.#rewind = rewind;
|
|
64
|
+
}
|
|
65
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
66
|
+
activate() {
|
|
67
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
68
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
69
|
+
if (first) {
|
|
70
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
74
|
+
deactivate() {
|
|
75
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
76
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
77
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
48
81
|
// src/utils/composition_tracker.ts
|
|
49
82
|
var CompositionTracker = class {
|
|
50
83
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -328,6 +361,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
328
361
|
/** Tracks IME lifecycle on the current input, including confirming keys without a signal. */
|
|
329
362
|
#composition = new CompositionTracker();
|
|
330
363
|
/** Restores authored custom-spinbutton ARIA when a target leaves or the controller stops. */
|
|
364
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
331
365
|
#ariaValueNow = new AttributeLease("aria-valuenow");
|
|
332
366
|
#ariaValueMin = new AttributeLease("aria-valuemin");
|
|
333
367
|
#ariaValueMax = new AttributeLease("aria-valuemax");
|
|
@@ -351,6 +385,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
351
385
|
/** Normalizes any initial value and wires the focus/hold pointer guards. */
|
|
352
386
|
connect() {
|
|
353
387
|
this.#repaint.activate();
|
|
388
|
+
this.#beforeCache.activate();
|
|
354
389
|
this.#globalGuards = new AbortController();
|
|
355
390
|
const { signal } = this.#globalGuards;
|
|
356
391
|
for (const button of this.incrementTargets) this.#wireButton(button, 1);
|
|
@@ -364,6 +399,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
364
399
|
disconnect() {
|
|
365
400
|
this.#repaint.cancel();
|
|
366
401
|
this.#composition.disconnect();
|
|
402
|
+
this.#beforeCache.deactivate();
|
|
367
403
|
this.#globalGuards?.abort();
|
|
368
404
|
this.#globalGuards = null;
|
|
369
405
|
const buttons = /* @__PURE__ */ new Set([
|
|
@@ -697,6 +733,12 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
697
733
|
get #steppedRange() {
|
|
698
734
|
return { min: this.minValue, max: this.maxValue, step: this.stepValue };
|
|
699
735
|
}
|
|
736
|
+
/** Returns borrowed range ARIA before Turbo snapshots the page. */
|
|
737
|
+
#rewindForCache() {
|
|
738
|
+
this.#ariaValueNow.returnAll();
|
|
739
|
+
this.#ariaValueMin.returnAll();
|
|
740
|
+
this.#ariaValueMax.returnAll();
|
|
741
|
+
}
|
|
700
742
|
};
|
|
701
743
|
|
|
702
744
|
export { NumberInputController };
|
|
@@ -42,7 +42,8 @@ function inheritsFieldsetDisabled(control) {
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
function canTakeFocus(element) {
|
|
45
|
-
if (element.closest("[hidden]")) return false;
|
|
45
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
46
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
46
47
|
if (!("disabled" in element)) return true;
|
|
47
48
|
if (element.disabled) return false;
|
|
48
49
|
return !inheritsFieldsetDisabled(element);
|
|
@@ -13,7 +13,8 @@ function inheritsFieldsetDisabled(control) {
|
|
|
13
13
|
return false;
|
|
14
14
|
}
|
|
15
15
|
function canTakeFocus(element) {
|
|
16
|
-
if (element.closest("[hidden]")) return false;
|
|
16
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
17
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
17
18
|
if (!("disabled" in element)) return true;
|
|
18
19
|
if (element.disabled) return false;
|
|
19
20
|
return !inheritsFieldsetDisabled(element);
|