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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +472 -114
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +1927 -607
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +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) {
|