stimeo-ui 0.7.0 → 0.8.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.
- package/CHANGELOG.md +62 -0
- package/dist/controllers/auto_submit_controller.d.ts +14 -3
- package/dist/controllers/auto_submit_controller.js +94 -16
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +81 -47
- package/dist/controllers/carousel_controller.js +451 -100
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +39 -5
- package/dist/controllers/currency_input_controller.js +305 -74
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.d.ts +3 -1
- package/dist/controllers/direct_upload_controller.js +12 -2
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.d.ts +123 -29
- package/dist/controllers/file_dropzone_controller.js +386 -63
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +3 -1
- package/dist/controllers/flash_controller.js +3 -1
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +4 -2
- package/dist/controllers/frame_loading_controller.js +2 -1
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +42 -16
- package/dist/controllers/input_mask_controller.js +251 -76
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +51 -14
- package/dist/controllers/nested_form_controller.js +450 -42
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +61 -25
- package/dist/controllers/otp_controller.js +472 -114
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +3 -2
- package/dist/controllers/spinner_controller.js +7 -5
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +4 -1
- package/dist/controllers/submit_once_controller.js +3 -1
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.d.ts +30 -10
- package/dist/controllers/textarea_autosize_controller.js +131 -3
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/index.js +1927 -607
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +8 -1
- package/dist/inspector/cli.js +3 -1
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +3 -1
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +4 -4
- package/dist/inspector/manifest.json +182 -23
- package/package.json +2 -2
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/input_mask_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/composition_tracker.ts
|
|
6
|
+
var CompositionTracker = class {
|
|
7
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
8
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
9
|
+
#onStart;
|
|
10
|
+
#onEnd;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.#onStart = options.onStart;
|
|
13
|
+
this.#onEnd = options.onEnd;
|
|
14
|
+
}
|
|
15
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
16
|
+
observe(target) {
|
|
17
|
+
if (this.#observedTargets.has(target)) return;
|
|
18
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
19
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
20
|
+
this.#observedTargets.add(target);
|
|
21
|
+
}
|
|
22
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
23
|
+
unobserve(target) {
|
|
24
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
25
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
26
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
27
|
+
this.#activeTargets.delete(target);
|
|
28
|
+
}
|
|
29
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
30
|
+
disconnect() {
|
|
31
|
+
for (const target of this.#observedTargets) {
|
|
32
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
33
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
34
|
+
}
|
|
35
|
+
this.#observedTargets.clear();
|
|
36
|
+
this.#activeTargets.clear();
|
|
37
|
+
}
|
|
38
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
39
|
+
isComposing(event) {
|
|
40
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
41
|
+
}
|
|
42
|
+
#handleStart = (event) => {
|
|
43
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
44
|
+
this.#onStart?.(event);
|
|
45
|
+
};
|
|
46
|
+
#handleEnd = (event) => {
|
|
47
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
48
|
+
this.#onEnd?.(event);
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/utils/focus_candidate.ts
|
|
53
|
+
function inheritsFieldsetDisabled(control) {
|
|
54
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
55
|
+
while (fieldset) {
|
|
56
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
57
|
+
if (!legend?.contains(control)) return true;
|
|
58
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/utils/half_width.ts
|
|
64
|
+
var FULL_WIDTH_SHIFT = 65248;
|
|
65
|
+
function halfWidthChar(char) {
|
|
66
|
+
if (char >= "\uFF01" && char <= "\uFF5E") {
|
|
67
|
+
return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);
|
|
68
|
+
}
|
|
69
|
+
return char === "\u3000" ? " " : char;
|
|
70
|
+
}
|
|
71
|
+
|
|
3
72
|
// src/controllers/input_mask_controller.ts
|
|
4
73
|
var DEFAULT_TOKENS = {
|
|
5
74
|
"9": "\\d",
|
|
@@ -7,6 +76,11 @@ var DEFAULT_TOKENS = {
|
|
|
7
76
|
"*": "[A-Za-z0-9]"
|
|
8
77
|
};
|
|
9
78
|
var UNMASK_ATTR = "data-stimeo--input-mask-unmask";
|
|
79
|
+
function acceptedForm(regex, char) {
|
|
80
|
+
if (regex.test(char)) return char;
|
|
81
|
+
const half = halfWidthChar(char);
|
|
82
|
+
return half !== char && regex.test(half) ? half : null;
|
|
83
|
+
}
|
|
10
84
|
function applyMask(value, pattern, tokens) {
|
|
11
85
|
let masked = "";
|
|
12
86
|
let unmasked = "";
|
|
@@ -18,17 +92,21 @@ function applyMask(value, pattern, tokens) {
|
|
|
18
92
|
if (regex) totalTokens += 1;
|
|
19
93
|
if (valueIndex >= value.length) continue;
|
|
20
94
|
if (regex) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
95
|
+
let taken = null;
|
|
96
|
+
while (valueIndex < value.length) {
|
|
97
|
+
taken = acceptedForm(regex, value[valueIndex] ?? "");
|
|
98
|
+
if (taken !== null) break;
|
|
99
|
+
valueIndex += 1;
|
|
100
|
+
}
|
|
101
|
+
if (taken === null) continue;
|
|
102
|
+
masked += taken;
|
|
103
|
+
unmasked += taken;
|
|
26
104
|
tokenFlags.push(true);
|
|
27
105
|
valueIndex += 1;
|
|
28
106
|
} else {
|
|
29
107
|
masked += patternChar;
|
|
30
108
|
tokenFlags.push(false);
|
|
31
|
-
if (value[valueIndex] === patternChar) valueIndex += 1;
|
|
109
|
+
if (halfWidthChar(value[valueIndex] ?? "") === halfWidthChar(patternChar)) valueIndex += 1;
|
|
32
110
|
}
|
|
33
111
|
}
|
|
34
112
|
return {
|
|
@@ -38,67 +116,169 @@ function applyMask(value, pattern, tokens) {
|
|
|
38
116
|
tokenFlags
|
|
39
117
|
};
|
|
40
118
|
}
|
|
119
|
+
function compileTokens(declaration) {
|
|
120
|
+
const map = /* @__PURE__ */ new Map();
|
|
121
|
+
for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {
|
|
122
|
+
try {
|
|
123
|
+
map.set(key, new RegExp(`^(?:${source})$`));
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return map;
|
|
128
|
+
}
|
|
129
|
+
function parseTokens(declaration) {
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(declaration);
|
|
133
|
+
} catch {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
137
|
+
const tokens = {};
|
|
138
|
+
for (const [key, source] of Object.entries(parsed)) {
|
|
139
|
+
if (typeof source === "string") tokens[key] = source;
|
|
140
|
+
}
|
|
141
|
+
return tokens;
|
|
142
|
+
}
|
|
143
|
+
function deletionOf(event) {
|
|
144
|
+
const inputType = event instanceof InputEvent ? event.inputType : "";
|
|
145
|
+
if (!inputType.startsWith("delete")) return null;
|
|
146
|
+
return inputType.endsWith("Forward") ? "forward" : "backward";
|
|
147
|
+
}
|
|
148
|
+
function tokenCharOffset(tokenFlags, n) {
|
|
149
|
+
let seen = 0;
|
|
150
|
+
for (let i = 0; i < tokenFlags.length; i += 1) {
|
|
151
|
+
if (!tokenFlags[i]) continue;
|
|
152
|
+
seen += 1;
|
|
153
|
+
if (seen === n) return i;
|
|
154
|
+
}
|
|
155
|
+
return -1;
|
|
156
|
+
}
|
|
41
157
|
var InputMaskController = class extends Controller {
|
|
42
158
|
static values = {
|
|
43
159
|
pattern: { type: String, default: "" },
|
|
44
|
-
tokens: { type:
|
|
160
|
+
tokens: { type: String, default: "" },
|
|
45
161
|
unmaskToHidden: { type: Boolean, default: true }
|
|
46
162
|
};
|
|
47
163
|
static actions = ["format"];
|
|
48
|
-
static events = ["change"];
|
|
164
|
+
static events = ["change", "reconcile"];
|
|
165
|
+
/** The value this controller last committed, and the baseline both events compare. */
|
|
166
|
+
#lastValue = null;
|
|
167
|
+
#started = false;
|
|
168
|
+
/** Validated token map; the hot path never parses the `tokens` declaration. */
|
|
169
|
+
#tokens = compileTokens("");
|
|
170
|
+
/** Holds mid-composition input so the IME's uncommitted text is never rewritten. */
|
|
171
|
+
#composition = new CompositionTracker({ onEnd: () => this.#reformat("edit") });
|
|
172
|
+
/** Re-parses the declaration and re-formats under the tokens it now selects. */
|
|
173
|
+
tokensValueChanged() {
|
|
174
|
+
this.#tokens = compileTokens(this.tokensValue);
|
|
175
|
+
if (this.#started) this.#reformat("reconcile");
|
|
176
|
+
}
|
|
177
|
+
patternValueChanged() {
|
|
178
|
+
if (this.#started) this.#reformat("reconcile");
|
|
179
|
+
}
|
|
180
|
+
/** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */
|
|
181
|
+
unmaskToHiddenValueChanged() {
|
|
182
|
+
if (!this.#started) return;
|
|
183
|
+
if (!this.unmaskToHiddenValue) {
|
|
184
|
+
const sink = this.#resolveSink();
|
|
185
|
+
if (sink) sink.value = "";
|
|
186
|
+
}
|
|
187
|
+
this.#reformat("reconcile");
|
|
188
|
+
}
|
|
49
189
|
connect() {
|
|
50
|
-
this.#
|
|
190
|
+
this.#started = true;
|
|
191
|
+
this.#composition.observe(this.element);
|
|
192
|
+
this.#lastValue = this.element.value;
|
|
193
|
+
this.#reformat("reconcile");
|
|
194
|
+
}
|
|
195
|
+
disconnect() {
|
|
196
|
+
this.#started = false;
|
|
197
|
+
this.#composition.disconnect();
|
|
51
198
|
}
|
|
52
199
|
/** Formats the field on input, preserving the caret. Bound via `data-action`. */
|
|
53
|
-
format() {
|
|
54
|
-
this.#
|
|
200
|
+
format(event) {
|
|
201
|
+
if (this.#composition.isComposing(event)) return;
|
|
202
|
+
this.#reformat("edit", deletionOf(event));
|
|
55
203
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Core reformat: mask the current value, restore the caret, sync the sink and
|
|
206
|
+
* the state hooks, and report a moved value under the event `cause` selects.
|
|
207
|
+
*/
|
|
208
|
+
#reformat(cause, deletion = null) {
|
|
59
209
|
const input = this.element;
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
210
|
+
const raw = input.value;
|
|
211
|
+
if (this.patternValue === "") {
|
|
212
|
+
this.#flag("data-mask-complete", false);
|
|
213
|
+
this.#flag("data-mask-empty", raw.length === 0);
|
|
214
|
+
this.#lastValue = raw;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const caret = input.selectionStart ?? raw.length;
|
|
218
|
+
let anchor = this.#significantBefore(raw.slice(0, caret));
|
|
219
|
+
let result = applyMask(raw, this.patternValue, this.#tokens);
|
|
220
|
+
if (deletion !== null && result.masked === this.#lastValue) {
|
|
221
|
+
const target = deletion === "backward" ? anchor : anchor + 1;
|
|
222
|
+
const offset = tokenCharOffset(result.tokenFlags, target);
|
|
223
|
+
if (offset >= 0) {
|
|
224
|
+
anchor = target - 1;
|
|
225
|
+
result = applyMask(
|
|
226
|
+
result.masked.slice(0, offset) + result.masked.slice(offset + 1),
|
|
227
|
+
this.patternValue,
|
|
228
|
+
this.#tokens
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (input.readOnly || input.disabled || inheritsFieldsetDisabled(input)) {
|
|
233
|
+
this.#publish(result, raw);
|
|
234
|
+
this.#lastValue = raw;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
65
237
|
input.value = result.masked;
|
|
66
|
-
this.#restoreCaret(
|
|
238
|
+
this.#restoreCaret(result.tokenFlags, anchor);
|
|
239
|
+
this.#publish(result, result.masked);
|
|
240
|
+
if (result.masked === this.#lastValue) return;
|
|
241
|
+
this.#lastValue = result.masked;
|
|
242
|
+
const detail = { masked: result.masked, unmasked: result.unmasked, complete: result.complete };
|
|
243
|
+
if (cause === "edit") this.dispatch("change", { detail });
|
|
244
|
+
else this.dispatch("reconcile", { detail });
|
|
245
|
+
}
|
|
246
|
+
/** Syncs the raw-value sink and the state hooks for the `shown` field text. */
|
|
247
|
+
#publish(result, shown) {
|
|
67
248
|
if (this.unmaskToHiddenValue) {
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
249
|
+
const sink = this.#resolveSink();
|
|
250
|
+
if (sink) sink.value = result.unmasked;
|
|
70
251
|
}
|
|
71
252
|
this.#flag("data-mask-complete", result.complete);
|
|
72
|
-
this.#flag("data-mask-empty",
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
253
|
+
this.#flag("data-mask-empty", shown.length === 0);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* How many token slots the text before the caret fills. Masking that prefix is
|
|
257
|
+
* what makes a rejected character — or a literal that also matches a token —
|
|
258
|
+
* count exactly as the rendering counts it, so the caret cannot drift.
|
|
259
|
+
*/
|
|
260
|
+
#significantBefore(prefix) {
|
|
261
|
+
return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;
|
|
78
262
|
}
|
|
79
263
|
/** Places the caret after the `n`-th token char (skipping following literals). */
|
|
80
|
-
#restoreCaret(
|
|
264
|
+
#restoreCaret(tokenFlags, n) {
|
|
81
265
|
let position;
|
|
82
266
|
if (n <= 0) {
|
|
83
267
|
let i = 0;
|
|
84
268
|
while (i < tokenFlags.length && !tokenFlags[i]) i += 1;
|
|
85
269
|
position = i;
|
|
86
270
|
} else {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
while (j < tokenFlags.length && !tokenFlags[j]) j += 1;
|
|
95
|
-
position = j;
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
271
|
+
const offset = tokenCharOffset(tokenFlags, n);
|
|
272
|
+
if (offset < 0) {
|
|
273
|
+
position = tokenFlags.length;
|
|
274
|
+
} else {
|
|
275
|
+
let j = offset + 1;
|
|
276
|
+
while (j < tokenFlags.length && !tokenFlags[j]) j += 1;
|
|
277
|
+
position = j;
|
|
98
278
|
}
|
|
99
279
|
}
|
|
100
280
|
try {
|
|
101
|
-
|
|
281
|
+
this.element.setSelectionRange(position, position);
|
|
102
282
|
} catch {
|
|
103
283
|
}
|
|
104
284
|
}
|
|
@@ -107,52 +287,47 @@ var InputMaskController = class extends Controller {
|
|
|
107
287
|
* can coexist in one form:
|
|
108
288
|
*
|
|
109
289
|
* 1. **Explicit pairing** — a sink whose attribute value names this input's
|
|
110
|
-
* `id` (`data-stimeo--input-mask-unmask="zip"`)
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
290
|
+
* `id` (`data-stimeo--input-mask-unmask="zip"`).
|
|
291
|
+
* 2. **Nearest container** — otherwise, walking up from the input, the first
|
|
292
|
+
* *value-less* sink in the closest ancestor. Wrapped input+sink pairs each
|
|
293
|
+
* find their own sink, and the single form-level sink keeps working
|
|
294
|
+
* unchanged. A sink claimed by another input's id is never matched here.
|
|
295
|
+
*
|
|
296
|
+
* Both steps only ever consider the associated form's own controls, so a sink
|
|
297
|
+
* belonging to a different form (or to none) is never written, while one the
|
|
298
|
+
* `form` attribute associates from elsewhere in the document still resolves —
|
|
299
|
+
* for either the input or the sink, neither of which needs to contain or be
|
|
300
|
+
* contained by the form. A form-less input reads the document instead.
|
|
117
301
|
*/
|
|
118
|
-
#
|
|
302
|
+
#resolveSink() {
|
|
303
|
+
const candidates = this.#sinkCandidates();
|
|
119
304
|
const id = this.element.id;
|
|
120
305
|
if (id.length > 0) {
|
|
121
|
-
const
|
|
122
|
-
const quoted = id.replace(/["\\]/g, "\\$&");
|
|
123
|
-
const paired = scope.querySelector(`input[${UNMASK_ATTR}="${quoted}"]`);
|
|
306
|
+
const paired = candidates.find((sink) => sink.getAttribute(UNMASK_ATTR) === id);
|
|
124
307
|
if (paired) return paired;
|
|
125
308
|
}
|
|
309
|
+
const free = candidates.filter((sink) => sink.getAttribute(UNMASK_ATTR) === "");
|
|
126
310
|
for (let node = this.element.parentElement; node !== null; node = node.parentElement) {
|
|
127
|
-
const
|
|
311
|
+
const ancestor = node;
|
|
312
|
+
const sink = free.find((candidate) => ancestor.contains(candidate));
|
|
128
313
|
if (sink) return sink;
|
|
129
|
-
if (node === this.element.form) break;
|
|
130
314
|
}
|
|
131
315
|
return null;
|
|
132
316
|
}
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return count;
|
|
145
|
-
}
|
|
146
|
-
/** Compiles the effective token map (defaults with the user `tokens` merged over). */
|
|
147
|
-
#tokenRegexes() {
|
|
148
|
-
const map = /* @__PURE__ */ new Map();
|
|
149
|
-
for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...this.tokensValue })) {
|
|
150
|
-
try {
|
|
151
|
-
map.set(key, new RegExp(`^(?:${source})$`));
|
|
152
|
-
} catch {
|
|
153
|
-
}
|
|
317
|
+
/**
|
|
318
|
+
* Every sink the input's form owns, in document order — one collection read per
|
|
319
|
+
* resolution, so the cost tracks the form's own controls and not the depth of
|
|
320
|
+
* the markup around it. `form.elements` lists controls the `form` attribute
|
|
321
|
+
* associates from anywhere in the document, and lists nothing another form owns.
|
|
322
|
+
*/
|
|
323
|
+
#sinkCandidates() {
|
|
324
|
+
const form = this.element.form;
|
|
325
|
+
if (form === null) {
|
|
326
|
+
return Array.from(document.querySelectorAll(`input[${UNMASK_ATTR}]`));
|
|
154
327
|
}
|
|
155
|
-
return
|
|
328
|
+
return Array.from(form.elements).filter(
|
|
329
|
+
(element) => element instanceof HTMLInputElement && element.hasAttribute(UNMASK_ATTR)
|
|
330
|
+
);
|
|
156
331
|
}
|
|
157
332
|
/** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
|
|
158
333
|
#flag(name, on) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/input_mask_controller.ts"],"names":[],"mappings":"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","file":"input_mask_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** Default placeholder tokens → single-char regex sources (user tokens merge over these). */\nconst DEFAULT_TOKENS: Readonly<Record<string, string>> = {\n \"9\": \"\\\\d\",\n a: \"[A-Za-z]\",\n \"*\": \"[A-Za-z0-9]\",\n};\n\n/** Attribute marking the hidden raw-value sink; its value may name the paired input's `id`. */\nconst UNMASK_ATTR = \"data-stimeo--input-mask-unmask\";\n\n/** The outcome of applying a fixed mask to a raw string. */\nexport interface MaskResult {\n /** The formatted (masked) string. */\n readonly masked: string;\n /** The significant characters only (literals/separators removed). */\n readonly unmasked: string;\n /** Whether every token slot in the pattern is filled. */\n readonly complete: boolean;\n /** Per-output-char flag: true where the char fills a token (not a literal). */\n readonly tokenFlags: readonly boolean[];\n}\n\n/**\n * Applies a fixed mask `pattern` to `value` using `tokens` (placeholder → regex).\n * Non-matching input characters are rejected (skipped); literals are auto-inserted\n * and a typed literal that matches is consumed. Pure and exported for direct testing.\n */\nexport function applyMask(\n value: string,\n pattern: string,\n tokens: ReadonlyMap<string, RegExp>,\n): MaskResult {\n let masked = \"\";\n let unmasked = \"\";\n const tokenFlags: boolean[] = [];\n let valueIndex = 0;\n let totalTokens = 0;\n\n for (const patternChar of pattern) {\n const regex = tokens.get(patternChar);\n if (regex) totalTokens += 1;\n if (valueIndex >= value.length) continue;\n\n if (regex) {\n // Skip rejected characters until one matches this token (or input runs out).\n while (valueIndex < value.length && !regex.test(value[valueIndex] ?? \"\")) valueIndex += 1;\n const char = value[valueIndex];\n if (char === undefined) continue;\n masked += char;\n unmasked += char;\n tokenFlags.push(true);\n valueIndex += 1;\n } else {\n masked += patternChar;\n tokenFlags.push(false);\n if (value[valueIndex] === patternChar) valueIndex += 1;\n }\n }\n\n return {\n masked,\n unmasked,\n complete: totalTokens > 0 && unmasked.length === totalTokens,\n tokenFlags,\n };\n}\n\n/**\n * Headless **input mask** — formats a field in place against a fixed pattern\n * (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,\n * rejecting invalid characters, and syncing the raw value to a hidden field. No\n * dedicated APG pattern; Currency Input owns money-specific formatting.\n *\n * Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):\n * <input type=\"text\" inputmode=\"numeric\"\n * data-controller=\"stimeo--input-mask\"\n * data-stimeo--input-mask-pattern-value=\"999-9999\"\n * data-action=\"input->stimeo--input-mask#format\">\n * <input type=\"hidden\" name=\"zip\" data-stimeo--input-mask-unmask>\n *\n * `change` dispatches `{ masked, unmasked, complete }`.\n *\n * @remarks\n * Behavior only and **idempotent** — the formatted value lives only in the input and\n * the hidden field (no module-scope state), so `connect()` re-formats the existing\n * value and is stable across Turbo restore/morph. The controller sits on the\n * `<input>` itself (a void element), so the raw-value sink is not a Stimulus target;\n * it is the `[data-stimeo--input-mask-unmask]` field resolved nearest-first within\n * the same form (an explicit pairing by the sink attribute's value naming the\n * input's `id` wins; otherwise the closest ancestor's value-less sink) — when one\n * form holds several masked inputs, wrap each input+sink pair in a container or\n * pair them by `id`. Formatting never steals keys: it rejects disallowed characters\n * silently and keeps the caret on insert, Backspace, and range replacement\n * (WCAG 2.2 3.3.2 / 1.3.5; the expected format is the consumer's `aria-describedby`).\n * It reflects `data-mask-complete` / `data-mask-empty` and dispatches\n * `stimeo--input-mask:change` only when the value actually changes.\n */\nexport class InputMaskController extends Controller<HTMLInputElement> {\n static override values = {\n pattern: { type: String, default: \"\" },\n tokens: { type: Object, default: {} },\n unmaskToHidden: { type: Boolean, default: true },\n };\n static actions = [\"format\"] as const;\n static events = [\"change\"] as const;\n\n declare patternValue: string;\n declare tokensValue: Record<string, string>;\n declare unmaskToHiddenValue: boolean;\n\n override connect(): void {\n // Re-format any server-rendered/restored value so the field is consistent.\n this.#apply();\n }\n\n /** Formats the field on input, preserving the caret. Bound via `data-action`. */\n format(): void {\n this.#apply();\n }\n\n /** Core reformat: mask the current value, restore the caret, sync, and announce. */\n #apply(): void {\n // No pattern → act as a pass-through. Without this guard `applyMask` would\n // produce an empty string and blank a misconfigured field's value.\n if (!this.patternValue) return;\n\n const input = this.element;\n const previous = input.value;\n const caret = input.selectionStart ?? previous.length;\n const tokens = this.#tokenRegexes();\n\n const significant = this.#countSignificant(previous.slice(0, caret), tokens);\n const result = applyMask(previous, this.patternValue, tokens);\n\n input.value = result.masked;\n this.#restoreCaret(input, result.tokenFlags, significant);\n\n if (this.unmaskToHiddenValue) {\n const unmask = this.#unmaskField();\n if (unmask) unmask.value = result.unmasked;\n }\n this.#flag(\"data-mask-complete\", result.complete);\n this.#flag(\"data-mask-empty\", result.masked.length === 0);\n\n if (result.masked !== previous) {\n this.dispatch(\"change\", {\n detail: { masked: result.masked, unmasked: result.unmasked, complete: result.complete },\n });\n }\n }\n\n /** Places the caret after the `n`-th token char (skipping following literals). */\n #restoreCaret(input: HTMLInputElement, tokenFlags: readonly boolean[], n: number): void {\n let position: number;\n if (n <= 0) {\n // Sit after any leading literals, before the first token slot.\n let i = 0;\n while (i < tokenFlags.length && !tokenFlags[i]) i += 1;\n position = i;\n } else {\n let seen = 0;\n position = tokenFlags.length;\n for (let i = 0; i < tokenFlags.length; i += 1) {\n if (!tokenFlags[i]) continue;\n seen += 1;\n if (seen === n) {\n let j = i + 1;\n while (j < tokenFlags.length && !tokenFlags[j]) j += 1;\n position = j;\n break;\n }\n }\n }\n try {\n input.setSelectionRange(position, position);\n } catch {\n /* selection unsupported for this input type — value formatting still applies */\n }\n }\n\n /**\n * The hidden raw-value sink for this input, resolved so several masked inputs\n * can coexist in one form:\n *\n * 1. **Explicit pairing** — a sink whose attribute value names this input's\n * `id` (`data-stimeo--input-mask-unmask=\"zip\"`), looked up across the form\n * (or the document when the input is form-less).\n * 2. **Nearest container** — otherwise, walking up from the input (stopping at\n * the form boundary), the first *value-less* sink in the closest ancestor.\n * Wrapped input+sink pairs each find their own sink, and the single\n * form-level sink keeps working unchanged. A sink claimed by another\n * input's id is never matched here.\n */\n #unmaskField(): HTMLInputElement | null {\n // Runs on every keystroke (via format → #apply), so each step is a single\n // engine-side `querySelector` scoped to `input` — no candidate loops in JS.\n const id = this.element.id;\n if (id.length > 0) {\n const scope: ParentNode = this.element.form ?? document;\n // Escape `\"` / `\\` so an authored id cannot break out of the quoted selector.\n const quoted = id.replace(/[\"\\\\]/g, \"\\\\$&\");\n const paired = scope.querySelector<HTMLInputElement>(`input[${UNMASK_ATTR}=\"${quoted}\"]`);\n if (paired) return paired;\n }\n // `[attr=\"\"]` matches bare (value-less) attributes, so a sink naming another\n // input's id can never be claimed by this fallback.\n for (let node = this.element.parentElement; node !== null; node = node.parentElement) {\n const sink = node.querySelector<HTMLInputElement>(`input[${UNMASK_ATTR}=\"\"]`);\n if (sink) return sink;\n if (node === this.element.form) break;\n }\n return null;\n }\n\n /** Counts characters in `text` that fill any token (the caret-significant chars). */\n #countSignificant(text: string, tokens: ReadonlyMap<string, RegExp>): number {\n let count = 0;\n for (const char of text) {\n for (const regex of tokens.values()) {\n if (regex.test(char)) {\n count += 1;\n break;\n }\n }\n }\n return count;\n }\n\n /** Compiles the effective token map (defaults with the user `tokens` merged over). */\n #tokenRegexes(): Map<string, RegExp> {\n const map = new Map<string, RegExp>();\n for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...this.tokensValue })) {\n try {\n map.set(key, new RegExp(`^(?:${source})$`));\n } catch {\n /* skip an invalid token regex rather than breaking the whole mask */\n }\n }\n return map;\n }\n\n /** Sets a boolean `data-*` flag to `\"true\"` when `on`, else removes it. */\n #flag(name: string, on: boolean): void {\n if (on) this.element.setAttribute(name, \"true\");\n else this.element.removeAttribute(name);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/focus_candidate.ts","../../src/utils/half_width.ts","../../src/controllers/input_mask_controller.ts"],"names":[],"mappings":"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","file":"input_mask_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported for the consumer that needs this rule *without* the rest of\n * {@link canTakeFocus}: `toolbar` bounds its own `hidden` walk at the toolbar\n * root, which is a different rule, so it composes the two itself.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the three conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","/** The distance between a full-width ASCII code point and its half-width twin. */\nconst FULL_WIDTH_SHIFT = 0xfee0;\n\n/**\n * The half-width form of `char`, or `char` itself when it has none.\n *\n * An IME confirming in full-width mode produces U+FF01–U+FF5E (and U+3000 for\n * the space) where the page means ASCII — the digits of a phone number, the\n * letters of a code, the separators between them. Reading those as their ASCII\n * equivalents keeps confirmed text usable instead of discarding it. Characters\n * outside that block, including kana and astral code points, are returned\n * unchanged, so this never rewrites text a consumer meant to keep as typed.\n */\nexport function halfWidthChar(char: string): string {\n if (char >= \"!\" && char <= \"~\") {\n return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);\n }\n return char === \" \" ? \" \" : char;\n}\n\n/** Every full-width ASCII character in `text` rewritten by {@link halfWidthChar}. */\nexport function toHalfWidth(text: string): string {\n let out = \"\";\n for (const char of text) out += halfWidthChar(char);\n return out;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\nimport { inheritsFieldsetDisabled } from \"../utils/focus_candidate\";\nimport { halfWidthChar } from \"../utils/half_width\";\n\n/** Default placeholder tokens → single-char regex sources (user tokens merge over these). */\nconst DEFAULT_TOKENS: Readonly<Record<string, string>> = {\n \"9\": \"\\\\d\",\n a: \"[A-Za-z]\",\n \"*\": \"[A-Za-z0-9]\",\n};\n\n/** Attribute marking the hidden raw-value sink; its value may name the paired input's `id`. */\nconst UNMASK_ATTR = \"data-stimeo--input-mask-unmask\";\n\n/** Which neighbour a deleting keystroke aimed at, when the platform names one. */\ntype Deletion = \"backward\" | \"forward\";\n\n/** Who moved the value: the user editing the field, or the controller re-deciding it. */\ntype Cause = \"edit\" | \"reconcile\";\n\n/** The outcome of applying a fixed mask to a raw string. */\nexport interface MaskResult {\n /** The formatted (masked) string. */\n readonly masked: string;\n /** The significant characters only (literals/separators removed). */\n readonly unmasked: string;\n /** Whether every token slot in the pattern is filled. */\n readonly complete: boolean;\n /** Per-output-char flag: true where the char fills a token (not a literal). */\n readonly tokenFlags: readonly boolean[];\n}\n\n/**\n * The form of `char` this token takes: as typed when the token accepts it, else\n * its half-width form when *that* is what the token accepts, else `null` for a\n * rejected character. Trying the character as typed first keeps a token written\n * for full-width text (or for text an IME leaves as kana) authoritative.\n */\nfunction acceptedForm(regex: RegExp, char: string): string | null {\n if (regex.test(char)) return char;\n const half = halfWidthChar(char);\n return half !== char && regex.test(half) ? half : null;\n}\n\n/**\n * Applies a fixed mask `pattern` to `value` using `tokens` (placeholder → regex).\n * Non-matching input characters are rejected (skipped); literals are auto-inserted\n * and a typed literal that matches is consumed. A character an IME confirmed in\n * full-width form fills a token — or consumes a literal — when its half-width\n * form is the one the mask asked for, and the half-width form is what the output\n * carries. Pure and exported for direct testing.\n */\nexport function applyMask(\n value: string,\n pattern: string,\n tokens: ReadonlyMap<string, RegExp>,\n): MaskResult {\n let masked = \"\";\n let unmasked = \"\";\n const tokenFlags: boolean[] = [];\n let valueIndex = 0;\n let totalTokens = 0;\n\n for (const patternChar of pattern) {\n const regex = tokens.get(patternChar);\n if (regex) totalTokens += 1;\n if (valueIndex >= value.length) continue;\n\n if (regex) {\n // Skip rejected characters until one fills this token (or input runs out).\n let taken: string | null = null;\n while (valueIndex < value.length) {\n taken = acceptedForm(regex, value[valueIndex] ?? \"\");\n if (taken !== null) break;\n valueIndex += 1;\n }\n if (taken === null) continue;\n masked += taken;\n unmasked += taken;\n tokenFlags.push(true);\n valueIndex += 1;\n } else {\n masked += patternChar;\n tokenFlags.push(false);\n // The literal is already in the output, so the typed character only has to\n // be the one that renders it — in either width.\n if (halfWidthChar(value[valueIndex] ?? \"\") === halfWidthChar(patternChar)) valueIndex += 1;\n }\n }\n\n return {\n masked,\n unmasked,\n complete: totalTokens > 0 && unmasked.length === totalTokens,\n tokenFlags,\n };\n}\n\n/**\n * The token map a `tokens` declaration selects: the defaults with the declared\n * single-char regex sources merged over. A declaration that is not a JSON object\n * of string sources falls back to the defaults, and an individual source that is\n * not a valid regex is skipped, so a broken declaration keeps the field working\n * instead of taking the mask (and every later keystroke) down with it.\n */\nfunction compileTokens(declaration: string): Map<string, RegExp> {\n const map = new Map<string, RegExp>();\n for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {\n try {\n map.set(key, new RegExp(`^(?:${source})$`));\n } catch {\n /* skip an invalid token regex rather than breaking the whole mask */\n }\n }\n return map;\n}\n\n/** The `{ token: source }` pairs a declaration holds; anything else reads as none. */\nfunction parseTokens(declaration: string): Record<string, string> {\n let parsed: unknown;\n try {\n parsed = JSON.parse(declaration);\n } catch {\n return {};\n }\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return {};\n\n const tokens: Record<string, string> = {};\n for (const [key, source] of Object.entries(parsed)) {\n if (typeof source === \"string\") tokens[key] = source;\n }\n return tokens;\n}\n\n/** The direction an `input` event deleted in, or `null` when it is not a deletion. */\nfunction deletionOf(event: Event | undefined): Deletion | null {\n const inputType = event instanceof InputEvent ? event.inputType : \"\";\n if (!inputType.startsWith(\"delete\")) return null;\n // Word, line, and content deletions all name their direction; the ones that\n // name none (a cut, a drag-out) took a selection, so read them as backward.\n return inputType.endsWith(\"Forward\") ? \"forward\" : \"backward\";\n}\n\n/** The offset of the `n`-th token char in a masked string, or `-1` when there is none. */\nfunction tokenCharOffset(tokenFlags: readonly boolean[], n: number): number {\n let seen = 0;\n for (let i = 0; i < tokenFlags.length; i += 1) {\n if (!tokenFlags[i]) continue;\n seen += 1;\n if (seen === n) return i;\n }\n return -1;\n}\n\n/**\n * Headless **input mask** — formats a field in place against a fixed pattern\n * (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,\n * rejecting invalid characters, and syncing the raw value to a hidden field. No\n * dedicated APG pattern; Currency Input owns money-specific formatting.\n *\n * Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):\n * <input type=\"text\" inputmode=\"numeric\"\n * data-controller=\"stimeo--input-mask\"\n * data-stimeo--input-mask-pattern-value=\"999-9999\"\n * data-action=\"input->stimeo--input-mask#format\">\n * <input type=\"hidden\" name=\"zip\" data-stimeo--input-mask-unmask>\n *\n * `change` and `reconcile` dispatch `{ masked, unmasked, complete }`.\n *\n * @remarks\n * Behavior only and **idempotent** — the formatted value lives only in the input and\n * the hidden field (no module-scope state), so `connect()` re-formats the existing\n * value and is stable across Turbo restore/morph. The controller sits on the\n * `<input>` itself (a void element), so the raw-value sink is not a Stimulus target;\n * it is the `[data-stimeo--input-mask-unmask]` field resolved nearest-first among\n * the associated form's own controls (an explicit pairing by the sink attribute's\n * value naming the input's `id` wins; otherwise the closest ancestor's value-less\n * sink) — when one form holds several masked inputs, wrap each input+sink pair in a\n * container or pair them by `id`.\n *\n * Formatting never steals keys: it rejects disallowed characters silently and keeps\n * the caret on insert, Backspace, and range replacement (WCAG 2.2 3.3.2 / 1.3.5;\n * the expected format is the consumer's `aria-describedby`). The caret anchors on\n * the token slots the text before it fills, so a rejected character leaves it\n * exactly where it was. Backspace and Delete always progress: when the keystroke\n * only removed literals the mask re-inserts, the significant character it aimed at\n * goes instead. Events fired mid-IME-composition are ignored; the confirmed text is\n * formatted once on `compositionend`.\n *\n * It reflects `data-mask-complete` / `data-mask-empty`, dispatches\n * `stimeo--input-mask:change` only when a user edit moves the committed value, and\n * `stimeo--input-mask:reconcile` when the controller itself decides that value —\n * a server-rendered or restored value normalized on connection, or a re-format\n * after `pattern` / `tokens` / `unmaskToHidden` changed. A malformed `tokens`\n * declaration falls back to the default tokens: it is parsed once in\n * `tokensValueChanged`, and the hot path only ever sees the validated map. A field\n * the user cannot edit (`readonly`, `disabled`, or inside a disabled `<fieldset>`)\n * keeps the value the page authored — the sink and the state hooks still follow it,\n * but the field itself is never rewritten. The composition listener is released on\n * `disconnect()`.\n */\nexport class InputMaskController extends Controller<HTMLInputElement> {\n static override values = {\n pattern: { type: String, default: \"\" },\n tokens: { type: String, default: \"\" },\n unmaskToHidden: { type: Boolean, default: true },\n };\n static actions = [\"format\"] as const;\n static events = [\"change\", \"reconcile\"] as const;\n\n declare patternValue: string;\n declare tokensValue: string;\n declare unmaskToHiddenValue: boolean;\n\n /** The value this controller last committed, and the baseline both events compare. */\n #lastValue: string | null = null;\n #started = false;\n\n /** Validated token map; the hot path never parses the `tokens` declaration. */\n #tokens = compileTokens(\"\");\n\n /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */\n readonly #composition = new CompositionTracker({ onEnd: () => this.#reformat(\"edit\") });\n\n /** Re-parses the declaration and re-formats under the tokens it now selects. */\n tokensValueChanged(): void {\n this.#tokens = compileTokens(this.tokensValue);\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n patternValueChanged(): void {\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */\n unmaskToHiddenValueChanged(): void {\n if (!this.#started) return;\n if (!this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = \"\";\n }\n this.#reformat(\"reconcile\");\n }\n\n override connect(): void {\n this.#started = true;\n this.#composition.observe(this.element);\n // Seed the baseline from the DOM so a value that is already masked stays\n // silent, then re-format any server-rendered/restored value.\n this.#lastValue = this.element.value;\n this.#reformat(\"reconcile\");\n }\n\n override disconnect(): void {\n this.#started = false;\n this.#composition.disconnect();\n }\n\n /** Formats the field on input, preserving the caret. Bound via `data-action`. */\n format(event?: Event): void {\n if (this.#composition.isComposing(event as InputEvent | undefined)) return;\n this.#reformat(\"edit\", deletionOf(event));\n }\n\n /**\n * Core reformat: mask the current value, restore the caret, sync the sink and\n * the state hooks, and report a moved value under the event `cause` selects.\n */\n #reformat(cause: Cause, deletion: Deletion | null = null): void {\n const input = this.element;\n const raw = input.value;\n\n // No pattern → act as a pass-through. Without this branch `applyMask` would\n // produce an empty string and blank a misconfigured field's value; the hooks\n // still describe the field so a pattern removed at runtime cannot leave a\n // stale `data-mask-complete` behind.\n if (this.patternValue === \"\") {\n this.#flag(\"data-mask-complete\", false);\n this.#flag(\"data-mask-empty\", raw.length === 0);\n this.#lastValue = raw;\n return;\n }\n\n const caret = input.selectionStart ?? raw.length;\n let anchor = this.#significantBefore(raw.slice(0, caret));\n let result = applyMask(raw, this.patternValue, this.#tokens);\n\n // A deletion that only took literals the mask re-inserts would land back on\n // the value it started from, so that keystroke could never delete anything:\n // drop the significant character it aimed at instead.\n if (deletion !== null && result.masked === this.#lastValue) {\n const target = deletion === \"backward\" ? anchor : anchor + 1;\n const offset = tokenCharOffset(result.tokenFlags, target);\n if (offset >= 0) {\n anchor = target - 1;\n result = applyMask(\n result.masked.slice(0, offset) + result.masked.slice(offset + 1),\n this.patternValue,\n this.#tokens,\n );\n }\n }\n\n // The value of a field the user cannot edit belongs to the page: publish the\n // derived outputs against it, but never rewrite it and never report a move.\n if (input.readOnly || input.disabled || inheritsFieldsetDisabled(input)) {\n this.#publish(result, raw);\n this.#lastValue = raw;\n return;\n }\n\n input.value = result.masked;\n this.#restoreCaret(result.tokenFlags, anchor);\n this.#publish(result, result.masked);\n\n if (result.masked === this.#lastValue) return;\n this.#lastValue = result.masked;\n const detail = { masked: result.masked, unmasked: result.unmasked, complete: result.complete };\n if (cause === \"edit\") this.dispatch(\"change\", { detail });\n else this.dispatch(\"reconcile\", { detail });\n }\n\n /** Syncs the raw-value sink and the state hooks for the `shown` field text. */\n #publish(result: MaskResult, shown: string): void {\n if (this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = result.unmasked;\n }\n this.#flag(\"data-mask-complete\", result.complete);\n this.#flag(\"data-mask-empty\", shown.length === 0);\n }\n\n /**\n * How many token slots the text before the caret fills. Masking that prefix is\n * what makes a rejected character — or a literal that also matches a token —\n * count exactly as the rendering counts it, so the caret cannot drift.\n */\n #significantBefore(prefix: string): number {\n return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;\n }\n\n /** Places the caret after the `n`-th token char (skipping following literals). */\n #restoreCaret(tokenFlags: readonly boolean[], n: number): void {\n let position: number;\n if (n <= 0) {\n // Sit after any leading literals, before the first token slot.\n let i = 0;\n while (i < tokenFlags.length && !tokenFlags[i]) i += 1;\n position = i;\n } else {\n const offset = tokenCharOffset(tokenFlags, n);\n if (offset < 0) {\n position = tokenFlags.length;\n } else {\n let j = offset + 1;\n while (j < tokenFlags.length && !tokenFlags[j]) j += 1;\n position = j;\n }\n }\n try {\n this.element.setSelectionRange(position, position);\n } catch {\n /* selection unsupported for this input type — value formatting still applies */\n }\n }\n\n /**\n * The hidden raw-value sink for this input, resolved so several masked inputs\n * can coexist in one form:\n *\n * 1. **Explicit pairing** — a sink whose attribute value names this input's\n * `id` (`data-stimeo--input-mask-unmask=\"zip\"`).\n * 2. **Nearest container** — otherwise, walking up from the input, the first\n * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each\n * find their own sink, and the single form-level sink keeps working\n * unchanged. A sink claimed by another input's id is never matched here.\n *\n * Both steps only ever consider the associated form's own controls, so a sink\n * belonging to a different form (or to none) is never written, while one the\n * `form` attribute associates from elsewhere in the document still resolves —\n * for either the input or the sink, neither of which needs to contain or be\n * contained by the form. A form-less input reads the document instead.\n */\n #resolveSink(): HTMLInputElement | null {\n const candidates = this.#sinkCandidates();\n const id = this.element.id;\n if (id.length > 0) {\n const paired = candidates.find((sink) => sink.getAttribute(UNMASK_ATTR) === id);\n if (paired) return paired;\n }\n\n // A bare (value-less) attribute is unclaimed, so a sink naming another\n // input's id can never be taken by this fallback.\n const free = candidates.filter((sink) => sink.getAttribute(UNMASK_ATTR) === \"\");\n for (let node = this.element.parentElement; node !== null; node = node.parentElement) {\n const ancestor = node;\n const sink = free.find((candidate) => ancestor.contains(candidate));\n if (sink) return sink;\n }\n return null;\n }\n\n /**\n * Every sink the input's form owns, in document order — one collection read per\n * resolution, so the cost tracks the form's own controls and not the depth of\n * the markup around it. `form.elements` lists controls the `form` attribute\n * associates from anywhere in the document, and lists nothing another form owns.\n */\n #sinkCandidates(): HTMLInputElement[] {\n const form = this.element.form;\n if (form === null) {\n return Array.from(document.querySelectorAll<HTMLInputElement>(`input[${UNMASK_ATTR}]`));\n }\n return Array.from(form.elements).filter(\n (element): element is HTMLInputElement =>\n element instanceof HTMLInputElement && element.hasAttribute(UNMASK_ATTR),\n );\n }\n\n /** Sets a boolean `data-*` flag to `\"true\"` when `on`, else removes it. */\n #flag(name: string, on: boolean): void {\n if (on) this.element.setAttribute(name, \"true\");\n else this.element.removeAttribute(name);\n }\n}\n"]}
|
|
@@ -2,10 +2,9 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Headless **nested / dynamic fields** for Rails `fields_for` +
|
|
5
|
-
* `accepts_nested_attributes_for` (no dedicated APG pattern — form editing).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* dropping them from the DOM (unsaved).
|
|
5
|
+
* `accepts_nested_attributes_for` (no dedicated APG pattern — form editing).
|
|
6
|
+
* Clone a `<template>` row, renumber its index, and remove rows by flagging
|
|
7
|
+
* `_destroy` (persisted) or dropping them from the DOM (unsaved).
|
|
9
8
|
*
|
|
10
9
|
* Markup contract (identifier: `stimeo--nested-form`):
|
|
11
10
|
* <div data-controller="stimeo--nested-form" data-stimeo--nested-form-min-value="1">
|
|
@@ -22,18 +21,43 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
22
21
|
* data-action="click->stimeo--nested-form#add">Add</button>
|
|
23
22
|
* </div>
|
|
24
23
|
*
|
|
25
|
-
* `
|
|
24
|
+
* Values: `min` / `max` bound the effective row count (`max` `0` = unlimited;
|
|
25
|
+
* both are followed at runtime), `indexPlaceholder` is the template token
|
|
26
|
+
* replaced per row (default `__INDEX__`), and `announce` + `countMessage` (a
|
|
27
|
+
* `{count}` template) opt into the announcer bridge.
|
|
28
|
+
*
|
|
29
|
+
* `add` dispatches `{ index, element }`; `remove` dispatches `{ element, persisted }`;
|
|
30
|
+
* `reconcile` dispatches `{ count, atMin, atMax }` when a change the controller did
|
|
31
|
+
* not perform itself — rows appended or removed by Turbo Streams / a morph, or a
|
|
32
|
+
* runtime `min` / `max` change — moves the published state.
|
|
26
33
|
*
|
|
27
34
|
* @remarks
|
|
28
35
|
* Behavior only — server-side `accepts_nested_attributes_for`, per-field
|
|
29
36
|
* validation, and reordering are out of scope. Row state lives **only** in the DOM
|
|
30
37
|
* (inserted nodes + each `_destroy` hidden input); there is no module-scope index
|
|
31
|
-
* counter, so the controller stays idempotent across Turbo swaps.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
38
|
+
* counter, so the controller stays idempotent across Turbo swaps. A row counts as
|
|
39
|
+
* destroyed when its own `_destroy` flag holds a truthy value — `hidden` is the
|
|
40
|
+
* visual half the controller writes alongside the flag, so a consumer hiding rows
|
|
41
|
+
* for other reasons does not affect the count. Remove buttons and destroy flags
|
|
42
|
+
* are resolved by **delegation scoped to their nearest nested-form root**, so
|
|
43
|
+
* dynamically-added rows work without per-row `data-action` and one instance
|
|
44
|
+
* nested inside another never acts on the inner instance's buttons or flags.
|
|
45
|
+
* External row changes are observed on the list and reconciled once per mutation
|
|
46
|
+
* batch. Adding a row moves focus to its first tab stop; removing returns focus to
|
|
47
|
+
* the nearest surviving row's first tab stop, falling back to the add button and
|
|
48
|
+
* finally to the root via a temporary `tabindex` (WCAG 2.2 2.4.3) — candidates
|
|
49
|
+
* that cannot take focus (natively `disabled`, inside `fieldset[disabled]`, or not
|
|
50
|
+
* rendered) are skipped. Count changes from the controller's own add / remove are
|
|
51
|
+
* announced through the shared `stimeo--announcer` (WCAG 2.2 4.1.3) when
|
|
52
|
+
* `announce` + `countMessage` are set; reconciliation stays silent to assistive
|
|
53
|
+
* tech. The add button's `disabled` is managed only while `max` is set, and the
|
|
54
|
+
* authored value is restored on teardown. A template must produce exactly one
|
|
55
|
+
* root element; markup lacking the required `list` / `template` targets, or a
|
|
56
|
+
* template producing anything else, is named on the console once per connection
|
|
57
|
+
* and every operation stays a safe no-op with nothing left in the list. Clicking
|
|
58
|
+
* remove on a row whose flag is already truthy only completes its hiding —
|
|
59
|
+
* nothing effective changes, so no event and no announcement. The delegated
|
|
60
|
+
* listener, the observer, and every lease are released on `disconnect()`.
|
|
37
61
|
*/
|
|
38
62
|
declare class NestedFormController extends Controller<HTMLElement> {
|
|
39
63
|
#private;
|
|
@@ -61,7 +85,7 @@ declare class NestedFormController extends Controller<HTMLElement> {
|
|
|
61
85
|
};
|
|
62
86
|
};
|
|
63
87
|
static actions: readonly ["add"];
|
|
64
|
-
static events: readonly ["add", "remove"];
|
|
88
|
+
static events: readonly ["add", "remove", "reconcile"];
|
|
65
89
|
readonly listTarget: HTMLElement;
|
|
66
90
|
readonly templateTarget: HTMLTemplateElement;
|
|
67
91
|
readonly addTarget: HTMLButtonElement;
|
|
@@ -75,10 +99,23 @@ declare class NestedFormController extends Controller<HTMLElement> {
|
|
|
75
99
|
countMessageValue: string;
|
|
76
100
|
connect(): void;
|
|
77
101
|
disconnect(): void;
|
|
102
|
+
/** Follows an arriving or swapped-in list: rebind to the primary, then reconcile. */
|
|
103
|
+
listTargetConnected(): void;
|
|
104
|
+
/** Follows a departing list the same way — the primary may have changed. */
|
|
105
|
+
listTargetDisconnected(): void;
|
|
106
|
+
/** Returns the lease with a departing add button; a new one re-arms on refresh. */
|
|
107
|
+
addTargetDisconnected(target: HTMLButtonElement): void;
|
|
108
|
+
addTargetConnected(): void;
|
|
109
|
+
/** Re-clamps when application code or a Turbo morph changes `min`. */
|
|
110
|
+
minValueChanged(): void;
|
|
111
|
+
/** Re-clamps when application code or a Turbo morph changes `max`. */
|
|
112
|
+
maxValueChanged(): void;
|
|
78
113
|
/**
|
|
79
114
|
* Clones the template row, replaces the index placeholder with a unique value,
|
|
80
|
-
* appends it, focuses its first
|
|
81
|
-
* `max
|
|
115
|
+
* appends it, focuses its first tab stop, and announces the new count. No-ops at
|
|
116
|
+
* `max`, when the required targets are missing (named on the console once per
|
|
117
|
+
* connection), or when the template does not produce exactly one root element
|
|
118
|
+
* (also named once; the insertion is rolled back so nothing accumulates).
|
|
82
119
|
*/
|
|
83
120
|
add(): void;
|
|
84
121
|
}
|