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.
@@ -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
- while (valueIndex < value.length && !regex.test(value[valueIndex] ?? "")) valueIndex += 1;
22
- const char = value[valueIndex];
23
- if (char === void 0) continue;
24
- masked += char;
25
- unmasked += char;
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: Object, default: {} },
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.#apply();
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.#apply();
200
+ format(event) {
201
+ if (this.#composition.isComposing(event)) return;
202
+ this.#reformat("edit", deletionOf(event));
55
203
  }
56
- /** Core reformat: mask the current value, restore the caret, sync, and announce. */
57
- #apply() {
58
- if (!this.patternValue) return;
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 previous = input.value;
61
- const caret = input.selectionStart ?? previous.length;
62
- const tokens = this.#tokenRegexes();
63
- const significant = this.#countSignificant(previous.slice(0, caret), tokens);
64
- const result = applyMask(previous, this.patternValue, tokens);
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(input, result.tokenFlags, significant);
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 unmask = this.#unmaskField();
69
- if (unmask) unmask.value = result.unmasked;
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", result.masked.length === 0);
73
- if (result.masked !== previous) {
74
- this.dispatch("change", {
75
- detail: { masked: result.masked, unmasked: result.unmasked, complete: result.complete }
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(input, tokenFlags, n) {
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
- let seen = 0;
88
- position = tokenFlags.length;
89
- for (let i = 0; i < tokenFlags.length; i += 1) {
90
- if (!tokenFlags[i]) continue;
91
- seen += 1;
92
- if (seen === n) {
93
- let j = i + 1;
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
- input.setSelectionRange(position, position);
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"`), looked up across the form
111
- * (or the document when the input is form-less).
112
- * 2. **Nearest container** — otherwise, walking up from the input (stopping at
113
- * the form boundary), the first *value-less* sink in the closest ancestor.
114
- * Wrapped input+sink pairs each find their own sink, and the single
115
- * form-level sink keeps working unchanged. A sink claimed by another
116
- * input's id is never matched here.
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
- #unmaskField() {
302
+ #resolveSink() {
303
+ const candidates = this.#sinkCandidates();
119
304
  const id = this.element.id;
120
305
  if (id.length > 0) {
121
- const scope = this.element.form ?? document;
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 sink = node.querySelector(`input[${UNMASK_ATTR}=""]`);
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
- /** Counts characters in `text` that fill any token (the caret-significant chars). */
134
- #countSignificant(text, tokens) {
135
- let count = 0;
136
- for (const char of text) {
137
- for (const regex of tokens.values()) {
138
- if (regex.test(char)) {
139
- count += 1;
140
- break;
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 map;
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) {