stimeo-ui 0.16.0 → 0.17.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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,14 +1,21 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/otp_controller.ts
3
+ function sharedRegistry(key, create) {
4
+ const symbol = Symbol.for(key);
5
+ const scope = globalThis;
6
+ const existing = scope[symbol];
7
+ if (existing !== void 0) return existing;
8
+ const registry = create();
9
+ Object.defineProperty(globalThis, symbol, { value: registry });
10
+ return registry;
11
+ }
4
12
 
5
- // src/utils/aria_ids.ts
6
- var counter = 0;
13
+ var ids = sharedRegistry("stimeo-ui.aria-ids.registry.v1", () => ({ counter: 0 }));
7
14
  function uniqueId(prefix = "stimeo") {
8
15
  let candidate;
9
16
  do {
10
- counter += 1;
11
- candidate = `${prefix}-${counter}`;
17
+ ids.counter += 1;
18
+ candidate = `${prefix}-${ids.counter}`;
12
19
  } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
13
20
  return candidate;
14
21
  }
@@ -19,12 +26,10 @@ function ensureId(element, prefix = "stimeo") {
19
26
  return id;
20
27
  }
21
28
 
22
- // src/utils/logical_scroll.ts
23
29
  function isRtl(element) {
24
30
  return window.getComputedStyle(element).direction === "rtl";
25
31
  }
26
32
 
27
- // src/utils/arrow_step.ts
28
33
  function logicalArrowKey(key, element) {
29
34
  if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
30
35
  if (!isRtl(element)) return key;
@@ -35,77 +40,143 @@ function isReservedArrowChord(event, allow = []) {
35
40
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
36
41
  }
37
42
 
38
- // src/utils/attribute_lease.ts
39
- var AttributeLease = class {
43
+ function isAttributeValue(value) {
44
+ return value === null || typeof value === "string";
45
+ }
46
+ function readLeaseRecord(element, record) {
47
+ try {
48
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
49
+ if (!Array.isArray(parsed)) return void 0;
50
+ const [original, written, ...rest] = parsed;
51
+ const linked = rest[0] !== false;
52
+ const beneath = linked ? rest : rest.slice(1);
53
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
54
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
55
+ } catch {
56
+ return void 0;
57
+ }
58
+ }
59
+ var AttributeLease = class _AttributeLease {
60
+ static #registry = sharedRegistry(
61
+ "stimeo-ui.attribute-lease.registry.v1",
62
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
63
+ );
40
64
  #attribute;
41
- #records = /* @__PURE__ */ new Map();
42
- /** @param attribute - The attribute whose temporary values this lease owns. */
43
- constructor(attribute) {
65
+ #record;
66
+ #base;
67
+ #held = /* @__PURE__ */ new Set();
68
+ constructor(attribute, owner, options = {}) {
44
69
  this.#attribute = attribute;
70
+ this.#record = `data-${owner}-${attribute}-lease`;
71
+ this.#base = options.base === true;
72
+ const names = _AttributeLease.#registry.names;
73
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
45
74
  }
46
- /** Writes or removes the leased attribute while preserving its authored value. */
47
75
  write(element, value) {
48
- const existing = this.#records.get(element);
49
- if (existing) {
50
- existing.written = value;
51
- } else {
52
- this.#records.set(element, {
53
- original: element.getAttribute(this.#attribute),
54
- written: value
55
- });
56
- }
57
- this.#reflect(element, value);
76
+ const stack = this.#stack(element);
77
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
78
+ this.#held.add(element);
79
+ const shows = !this.#covered(element, stack, hold);
80
+ hold.written = value;
81
+ this.#mark(element, stack);
82
+ if (shows) this.#reflect(element, value);
58
83
  }
59
- /** Returns one lease without overwriting a value subsequently authored by a consumer. */
60
84
  return(element) {
61
- const record = this.#records.get(element);
62
- if (!record) return;
63
- this.#records.delete(element);
64
- const stillOwned = element.getAttribute(this.#attribute) === record.written;
65
- if (stillOwned) this.#reflect(element, record.original);
85
+ const held = this.#held.delete(element);
86
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
87
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
88
+ const hold = this.#take(stack);
89
+ if (!hold) return false;
90
+ const carries = element.getAttribute(this.#attribute) === hold.written;
91
+ const owns = carries && !this.#covered(element, stack, hold);
92
+ const index = stack.indexOf(hold);
93
+ stack.splice(index, 1);
94
+ const above = stack[index];
95
+ if (above) above.linked &&= hold.linked;
96
+ this.#mark(element, stack);
97
+ if (!owns) return true;
98
+ const below = hold.linked ? stack[index - 1] : void 0;
99
+ this.#reflect(element, below ? below.written : hold.original);
100
+ return true;
101
+ }
102
+ returnAll() {
103
+ for (const element of Array.from(this.#held)) this.return(element);
104
+ }
105
+ #stack(element) {
106
+ const stacks = _AttributeLease.#registry.stacks;
107
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
108
+ stacks.set(element, byAttribute);
109
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
110
+ byAttribute.set(this.#attribute, stack);
111
+ return stack;
112
+ }
113
+ #seed(element) {
114
+ const found = [];
115
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
116
+ const read = readLeaseRecord(element, record);
117
+ if (read) found.push({ ...read, record });
118
+ }
119
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
120
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
121
+ lease: null,
122
+ record,
123
+ written,
124
+ original,
125
+ linked: linked && index > 0
126
+ }));
127
+ }
128
+ #take(stack) {
129
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
130
+ if (hold) hold.lease = this;
131
+ return hold;
132
+ }
133
+ #acquire(element, stack) {
134
+ const current = element.getAttribute(this.#attribute);
135
+ const top = stack.at(-1);
136
+ const bottom = stack[0];
137
+ const over = top?.written === current ? top : void 0;
138
+ const from = this.#base ? bottom : over;
139
+ const hold = {
140
+ lease: this,
141
+ record: this.#record,
142
+ written: current,
143
+ original: from ? from.original : current,
144
+ linked: !this.#base && over !== void 0
145
+ };
146
+ if (this.#base && bottom) {
147
+ bottom.linked = true;
148
+ stack.unshift(hold);
149
+ } else stack.push(hold);
150
+ return hold;
151
+ }
152
+ #covered(element, stack, hold) {
153
+ const current = element.getAttribute(this.#attribute);
154
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
155
+ }
156
+ #mark(element, stack) {
157
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
158
+ stack.forEach((hold, index) => {
159
+ const names = stack.slice(0, index).map((under) => under.record);
160
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
161
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
162
+ records.set(
163
+ hold.record,
164
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
165
+ );
166
+ });
167
+ for (const [name, recorded] of records) {
168
+ if (element.getAttribute(name) === recorded) continue;
169
+ if (recorded === null) element.removeAttribute(name);
170
+ else element.setAttribute(name, recorded);
171
+ }
66
172
  }
67
- /** Reflects only a real value transition, avoiding self-triggered mutation work. */
68
173
  #reflect(element, value) {
69
174
  if (element.getAttribute(this.#attribute) === value) return;
70
175
  if (value === null) element.removeAttribute(this.#attribute);
71
176
  else element.setAttribute(this.#attribute, value);
72
177
  }
73
- /** Returns every outstanding lease using the same ownership check as {@link return}. */
74
- returnAll() {
75
- for (const element of Array.from(this.#records.keys())) this.return(element);
76
- }
77
- };
78
-
79
- // src/utils/before_cache_reset.ts
80
- var BeforeCacheReset = class _BeforeCacheReset {
81
- /** Every subscribed instance, iterated by the one shared document listener. */
82
- static #subscribers = /* @__PURE__ */ new Set();
83
- /** The shared listener; installed while at least one instance is subscribed. */
84
- static #onBeforeCache = () => {
85
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
86
- };
87
- #rewind;
88
- /** @param rewind - the pass that returns this controller's state to its initial form. */
89
- constructor(rewind) {
90
- this.#rewind = rewind;
91
- }
92
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
93
- activate() {
94
- const first = _BeforeCacheReset.#subscribers.size === 0;
95
- _BeforeCacheReset.#subscribers.add(this);
96
- if (first) {
97
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
98
- }
99
- }
100
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
101
- deactivate() {
102
- _BeforeCacheReset.#subscribers.delete(this);
103
- if (_BeforeCacheReset.#subscribers.size > 0) return;
104
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
105
- }
106
178
  };
107
179
 
108
- // src/utils/composition_tracker.ts
109
180
  var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
110
181
  "insertCompositionText",
111
182
  "insertFromComposition",
@@ -117,13 +188,11 @@ var CompositionTracker = class {
117
188
  #activeTargets = /* @__PURE__ */ new Set();
118
189
  #onStart;
119
190
  #onEnd;
120
- /** The field whose confirming `input` is still owed, while the window is open. */
121
191
  #confirmedTarget = null;
122
192
  constructor(options = {}) {
123
193
  this.#onStart = options.onStart;
124
194
  this.#onEnd = options.onEnd;
125
195
  }
126
- /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
127
196
  observe(target) {
128
197
  if (this.#observedTargets.has(target)) return;
129
198
  target.addEventListener("compositionstart", this.#handleStart);
@@ -131,7 +200,6 @@ var CompositionTracker = class {
131
200
  target.addEventListener("keydown", this.#handleKeydown);
132
201
  this.#observedTargets.add(target);
133
202
  }
134
- /** Stops tracking one target and clears any active composition it owned. */
135
203
  unobserve(target) {
136
204
  if (!this.#observedTargets.delete(target)) return;
137
205
  target.removeEventListener("compositionstart", this.#handleStart);
@@ -140,7 +208,6 @@ var CompositionTracker = class {
140
208
  this.#activeTargets.delete(target);
141
209
  if (this.#confirmedTarget === target) this.#confirmedTarget = null;
142
210
  }
143
- /** Releases every listener and clears state so reconnect starts cleanly. */
144
211
  disconnect() {
145
212
  for (const target of this.#observedTargets) {
146
213
  target.removeEventListener("compositionstart", this.#handleStart);
@@ -151,16 +218,9 @@ var CompositionTracker = class {
151
218
  this.#activeTargets.clear();
152
219
  this.#confirmedTarget = null;
153
220
  }
154
- /** True when lifecycle tracking or the current event reports composition. */
155
221
  isComposing(event) {
156
222
  return this.#activeTargets.size > 0 || event?.isComposing === true;
157
223
  }
158
- /**
159
- * Whether `event` is the `input` echoing the composition just confirmed.
160
- *
161
- * Asking closes the window either way, so one confirmation is folded at most
162
- * once and a consumer asks once per `input`.
163
- */
164
224
  consumesConfirmedInput(event) {
165
225
  const confirmed = this.#confirmedTarget;
166
226
  this.#confirmedTarget = null;
@@ -178,13 +238,11 @@ var CompositionTracker = class {
178
238
  this.#confirmedTarget = event.target;
179
239
  this.#onEnd?.(event);
180
240
  };
181
- /** A key on an observed field opens an edit of its own, so no echo is owed. */
182
241
  #handleKeydown = () => {
183
242
  this.#confirmedTarget = null;
184
243
  };
185
244
  };
186
245
 
187
- // src/utils/declared_value.ts
188
246
  function parseDeclared(raw, parse, fallback) {
189
247
  try {
190
248
  return parse(raw);
@@ -203,25 +261,18 @@ function compileRegExp(source, anchor = "none") {
203
261
  );
204
262
  }
205
263
 
206
- // src/utils/form_reset_watcher.ts
207
264
  var FormResetWatcher = class {
208
265
  #owns;
209
266
  #onReset;
210
267
  #frames = /* @__PURE__ */ new Set();
211
268
  #generation = 0;
212
- /**
213
- * @param owns - whether `form` owns a control this controller derives from.
214
- * @param onReset - the reconciliation pass, run once per honoured reset.
215
- */
216
269
  constructor(owns, onReset) {
217
270
  this.#owns = owns;
218
271
  this.#onReset = onReset;
219
272
  }
220
- /** Subscribes to `reset` at the document; call from `connect()`. Idempotent. */
221
273
  observe() {
222
274
  document.addEventListener("reset", this.#handleReset, true);
223
275
  }
224
- /** Unsubscribes and drops every pending pass; call from `disconnect()`. */
225
276
  disconnect() {
226
277
  document.removeEventListener("reset", this.#handleReset, true);
227
278
  this.#generation += 1;
@@ -245,13 +296,11 @@ var FormResetWatcher = class {
245
296
  this.#frames.add(frame);
246
297
  });
247
298
  };
248
- /** Runs the pass unless a listener cancelled the reset before it landed. */
249
299
  #settle(event) {
250
300
  if (!event.defaultPrevented) this.#onReset();
251
301
  }
252
302
  };
253
303
 
254
- // src/utils/half_width.ts
255
304
  var FULL_WIDTH_SHIFT = 65248;
256
305
  function halfWidthChar(char) {
257
306
  if (char >= "\uFF01" && char <= "\uFF5E") {
@@ -265,27 +314,22 @@ function toHalfWidth(text) {
265
314
  return out;
266
315
  }
267
316
 
268
- // src/utils/microtask_coalescer.ts
269
317
  var MicrotaskCoalescer = class {
270
318
  #run;
271
319
  #queued = false;
272
320
  #active = false;
273
321
  #generation = 0;
274
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
275
322
  constructor(run) {
276
323
  this.#run = run;
277
324
  }
278
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
279
325
  activate() {
280
326
  this.#active = true;
281
327
  }
282
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
283
328
  cancel() {
284
329
  this.#active = false;
285
330
  this.#queued = false;
286
331
  this.#generation += 1;
287
332
  }
288
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
289
333
  schedule() {
290
334
  if (!this.#active || this.#queued) return;
291
335
  this.#queued = true;
@@ -298,7 +342,17 @@ var MicrotaskCoalescer = class {
298
342
  }
299
343
  };
300
344
 
301
- // src/controllers/otp_controller.ts
345
+ var MoveCounter = class {
346
+ #sequence = 0;
347
+ record() {
348
+ this.#sequence += 1;
349
+ return this.#sequence;
350
+ }
351
+ isLatest(token) {
352
+ return token === this.#sequence;
353
+ }
354
+ };
355
+
302
356
  var LEASE_MARKERS = {
303
357
  "aria-invalid": "data-otp-invalid-lease",
304
358
  "aria-errormessage": "data-otp-errormessage-lease",
@@ -316,8 +370,8 @@ function readLease(field, marker) {
316
370
  return null;
317
371
  }
318
372
  }
319
- function describedByWith(described, errorId) {
320
- const tokens = (described ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
373
+ function describedByWith(described, errorId, earlier) {
374
+ const tokens = (described ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId && token !== earlier);
321
375
  return [...tokens, errorId].join(" ");
322
376
  }
323
377
  var DEFAULT_PATTERN = "[0-9]";
@@ -329,36 +383,23 @@ function statesDiffer(left, right) {
329
383
  return left.value !== right.value || left.state !== right.state;
330
384
  }
331
385
  var OtpController = class extends Controller {
386
+ #moves = new MoveCounter();
332
387
  static targets = ["field", "value", "error"];
333
388
  static values = {
334
389
  pattern: { type: String, default: DEFAULT_PATTERN }
335
390
  };
336
391
  static actions = ["onInput", "onKeydown", "onPaste", "onPointerDown", "clear"];
337
392
  static events = ["change", "complete", "invalid", "reconcile"];
338
- /** Validated matcher; the hot path never compiles a raw declaration. */
339
393
  #pattern = DEFAULT_MATCHER;
340
- /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
341
394
  #patternSource = DEFAULT_PATTERN;
342
- /** Public state carried by the last dispatch; keeps a no-op sync silent. */
343
395
  #published = null;
344
- /** True between connect and disconnect, so pre-connect Value changes stay silent. */
345
396
  #connected = false;
346
- /** Digit each field last committed, restored when rejected input replaced it. */
347
397
  #committed = /* @__PURE__ */ new WeakMap();
348
- /** The field being composed, and the text it held when the composition began. */
349
398
  #composing = null;
350
- /** A `pattern` change whose rejected characters wait for the composition to end. */
351
399
  #dropHeld = false;
352
- /**
353
- * Collapses one batch of page changes — field target and `pattern` callbacks, a
354
- * form reset — into a single reconciliation.
355
- */
356
400
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileFields());
357
- #errorHidden = new AttributeLease("hidden");
358
- #state = new AttributeLease("data-state");
359
- /** Rewinds the transient error surface before Turbo freezes the page. */
360
- #beforeCache = new BeforeCacheReset(() => this.#clearError());
361
- /** Reads the fields back once a native reset restored them, as a reconciliation. */
401
+ #errorHidden = new AttributeLease("hidden", this.identifier);
402
+ #state = new AttributeLease("data-state", this.identifier);
362
403
  #formReset = new FormResetWatcher(
363
404
  (form) => this.#ownedBy(form),
364
405
  () => {
@@ -366,11 +407,6 @@ var OtpController = class extends Controller {
366
407
  this.#reconcile.schedule();
367
408
  }
368
409
  );
369
- /**
370
- * Owns IME lifecycle state across every digit field. When a composition ends, a
371
- * `pattern` drop it held back applies first — it was declared first — and the
372
- * user's commit is taken after it.
373
- */
374
410
  #composition = new CompositionTracker({
375
411
  onStart: (event) => {
376
412
  const field = this.#fieldFrom(event);
@@ -394,7 +430,6 @@ var OtpController = class extends Controller {
394
430
  this.#connected = true;
395
431
  for (const field of this.fieldTargets) this.#bind(field);
396
432
  this.#formReset.observe();
397
- this.#beforeCache.activate();
398
433
  this.#reconcile.activate();
399
434
  this.#adopt();
400
435
  this.#sync();
@@ -404,35 +439,34 @@ var OtpController = class extends Controller {
404
439
  for (const field of this.fieldTargets) this.#unbind(field);
405
440
  this.#composition.disconnect();
406
441
  this.#formReset.disconnect();
407
- this.#beforeCache.deactivate();
408
442
  this.#reconcile.cancel();
409
443
  this.#dropHeld = false;
410
444
  this.#clearError();
411
445
  this.#state.return(this.element);
412
446
  }
413
- /**
414
- * Stimulus lifecycle callback when a new field target enters the DOM.
415
- * Wires the new field and folds the wider digit count into one reconciliation,
416
- * which stays inert until `connect()` opens the window.
417
- */
418
447
  fieldTargetConnected(element) {
419
448
  this.#bind(element);
420
449
  this.#adoptField(element);
421
450
  this.#reconcile.schedule();
422
451
  }
423
- /** Releases a dropped field's listeners and leases, then reconciles the rest. */
424
452
  fieldTargetDisconnected(element) {
425
453
  this.#unbind(element);
426
454
  this.#returnLeases(element);
427
455
  this.#reconcile.schedule();
428
456
  }
429
- /**
430
- * Re-validates a changed `pattern` declaration once and drops any entered digit
431
- * the new pattern no longer accepts, so the combined value stays interpretable.
432
- * The page changed the declaration, so the move joins the same reconciliation
433
- * as fields added or removed in that mutation. While a field is composing, its
434
- * text is the IME's, and the drop waits for the composition to end.
435
- */
457
+ valueTargetConnected() {
458
+ this.#reconcile.schedule();
459
+ }
460
+ valueTargetDisconnected() {
461
+ this.#reconcile.schedule();
462
+ }
463
+ errorTargetConnected() {
464
+ this.#followError();
465
+ }
466
+ errorTargetDisconnected(element) {
467
+ if (!this.errorTargets.includes(element)) this.#errorHidden.return(element);
468
+ this.#followError();
469
+ }
436
470
  patternValueChanged() {
437
471
  const compiled = compileRegExp(this.patternValue, "exact");
438
472
  this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
@@ -444,7 +478,6 @@ var OtpController = class extends Controller {
444
478
  }
445
479
  if (this.#dropRejected()) this.#reconcile.schedule();
446
480
  }
447
- /** Empties every writable field holding a character the pattern rejects; whether one did. */
448
481
  #dropRejected() {
449
482
  let dropped = false;
450
483
  for (const field of this.fieldTargets) {
@@ -455,7 +488,6 @@ var OtpController = class extends Controller {
455
488
  }
456
489
  return dropped;
457
490
  }
458
- /** Handles keystroke inputs, distributes autofilled text, and advances focus. */
459
491
  onInput(event) {
460
492
  const input = this.#fieldFrom(event);
461
493
  if (!input) return;
@@ -463,7 +495,6 @@ var OtpController = class extends Controller {
463
495
  if (this.#composition.isComposing(event)) return;
464
496
  this.#accept(input);
465
497
  }
466
- /** Handles Backspace clearing, arrows, and home/end navigation. */
467
498
  onKeydown(event) {
468
499
  if (isReservedArrowChord(event)) return;
469
500
  const input = this.#fieldFrom(event);
@@ -526,18 +557,12 @@ var OtpController = class extends Controller {
526
557
  }
527
558
  }
528
559
  }
529
- /** Divides pasted string characters across the available input fields. */
530
560
  onPaste(event) {
531
561
  const input = this.#fieldFrom(event);
532
562
  if (!input) return;
533
563
  event.preventDefault();
534
564
  this.#distribute(input, toHalfWidth(event.clipboardData?.getData("text") ?? ""));
535
565
  }
536
- /**
537
- * Redirects a pointer landing on an empty field to the earliest empty one, so
538
- * a passcode is entered in order. Filled fields stay directly reachable for
539
- * correction, and keyboard focus is left alone.
540
- */
541
566
  onPointerDown(event) {
542
567
  const input = this.#fieldFrom(event);
543
568
  if (input?.value !== "") return;
@@ -546,7 +571,6 @@ var OtpController = class extends Controller {
546
571
  event.preventDefault();
547
572
  first.focus();
548
573
  }
549
- /** Empties every writable field and restarts entry at the first of them. */
550
574
  clear() {
551
575
  for (const field of this.fieldTargets) {
552
576
  if (this.#isWritable(field)) this.#writeField(field, "");
@@ -561,7 +585,6 @@ var OtpController = class extends Controller {
561
585
  input.select();
562
586
  }
563
587
  };
564
- /** Whether a form owns at least one field or the hidden combined value. */
565
588
  #ownedBy(form) {
566
589
  if (this.fieldTargets.some((field) => field.form === form)) return true;
567
590
  return this.hasValueTarget && this.valueTarget.form === form;
@@ -575,13 +598,10 @@ var OtpController = class extends Controller {
575
598
  this.#composition.unobserve(field);
576
599
  if (this.#composing?.field === field) this.#composing = null;
577
600
  }
578
- /** Reads every field back so a restored or reset group starts consistent. */
579
601
  #adopt() {
580
602
  for (const field of this.fieldTargets) this.#adoptField(field);
581
603
  this.#clearError();
582
- if (this.hasErrorTarget) this.errorTarget.setAttribute("hidden", "");
583
604
  }
584
- /** Writes one error attribute, keeping the first authored value it displaces. */
585
605
  #lease(field, attribute, value) {
586
606
  const marker = LEASE_MARKERS[attribute];
587
607
  const held = readLease(field, marker);
@@ -589,7 +609,6 @@ var OtpController = class extends Controller {
589
609
  field.setAttribute(marker, JSON.stringify({ authored, written: value }));
590
610
  field.setAttribute(attribute, value);
591
611
  }
592
- /** Gives one field's error attributes back where they still hold what this wrote. */
593
612
  #returnLeases(field) {
594
613
  for (const [attribute, marker] of Object.entries(LEASE_MARKERS)) {
595
614
  const held = readLease(field, marker);
@@ -600,31 +619,10 @@ var OtpController = class extends Controller {
600
619
  else field.setAttribute(attribute, held.authored);
601
620
  }
602
621
  }
603
- /** Takes one field's current value as the truth behind its derived state. */
604
622
  #adoptField(field) {
605
623
  this.#committed.set(field, field.value);
606
624
  this.#markFilled(field, field.value);
607
625
  }
608
- /**
609
- * Absorbs one batch of page changes — fields added or removed, entered digits a
610
- * new `pattern` rejects, fields a native form reset restored — as one state
611
- * transition.
612
- *
613
- * Nobody edited the passcode here, so the move is reported as `reconcile`:
614
- * automation listening for `change` must not read a re-render or a reset as an
615
- * edit, and a passcode that happens to end up full must not fire the
616
- * `complete` that submits it.
617
- *
618
- * Completeness moves on its own when the field count changes: dropping a
619
- * trailing empty field completes a passcode whose combined value never moved,
620
- * and adding one un-completes it. Comparing the whole derived state, not the
621
- * string it contains, is what makes those transitions reportable.
622
- *
623
- * A change already in the DOM — a field added or removed, a reset — is
624
- * published at once, even while a field is composing: that field counts by the
625
- * character it had committed, never by the IME's uncommitted text. Only a
626
- * `pattern` drop waits for the composition to end.
627
- */
628
626
  #reconcileFields() {
629
627
  if (this.#dropHeld && this.#composing === null) {
630
628
  this.#dropHeld = false;
@@ -633,13 +631,9 @@ var OtpController = class extends Controller {
633
631
  const previous = this.#published;
634
632
  const current = this.#sync();
635
633
  if (previous && !statesDiffer(previous, current)) return;
634
+ this.#moves.record();
636
635
  this.dispatch("reconcile", { detail: { value: current.value } });
637
636
  }
638
- /**
639
- * Validates the text an entry point received and distributes what it accepts.
640
- * `text` defaults to the field's own value; a confirmation passes the string it
641
- * committed, which `maxlength` would otherwise have truncated.
642
- */
643
637
  #accept(input, text = input.value) {
644
638
  const raw = toHalfWidth(text);
645
639
  if (raw === "") {
@@ -650,10 +644,6 @@ var OtpController = class extends Controller {
650
644
  }
651
645
  this.#distribute(input, raw);
652
646
  }
653
- /**
654
- * Fills `text`'s accepted characters into the writable fields at and after the
655
- * entry point, then leaves focus on the field after the last one filled.
656
- */
657
647
  #distribute(from, text) {
658
648
  const accepted = Array.from(text).filter((char) => this.#pattern.test(char));
659
649
  let reached = false;
@@ -678,11 +668,9 @@ var OtpController = class extends Controller {
678
668
  this.#clearError();
679
669
  this.#syncAndDispatch();
680
670
  }
681
- /** Restores the digit a field committed before rejected input replaced it. */
682
671
  #restore(field) {
683
672
  this.#writeField(field, this.#committed.get(field) ?? "");
684
673
  }
685
- /** Commits one field's value and the derived hook that reports it as entered. */
686
674
  #writeField(field, value) {
687
675
  field.value = value;
688
676
  this.#committed.set(field, value);
@@ -711,11 +699,9 @@ var OtpController = class extends Controller {
711
699
  #focusableBefore(index) {
712
700
  return this.#before(index).find((field) => this.#isFocusable(field)) ?? null;
713
701
  }
714
- /** Fields before `index`, nearest first; empty at the first field. */
715
702
  #before(index) {
716
703
  return this.fieldTargets.slice(0, Math.max(index, 0)).reverse();
717
704
  }
718
- /** The event's field target, or `null` when the wiring points somewhere else. */
719
705
  #fieldFrom(event) {
720
706
  const input = event.currentTarget;
721
707
  return this.fieldTargets.find((field) => field === input) ?? null;
@@ -723,19 +709,13 @@ var OtpController = class extends Controller {
723
709
  #combinedValue() {
724
710
  return this.fieldTargets.map((field) => this.#publishedText(field)).join("");
725
711
  }
726
- /** Every field carries a character, and there is at least one field. */
727
712
  #isComplete() {
728
713
  const fields = this.fieldTargets;
729
714
  return fields.length > 0 && fields.every((field) => this.#publishedText(field).length > 0);
730
715
  }
731
- /** A field's text as the combined value counts it: a composing field by its committed character. */
732
716
  #publishedText(field) {
733
717
  return field === this.#composing?.field ? this.#committed.get(field) ?? "" : field.value;
734
718
  }
735
- /**
736
- * Mirrors the combined value into the form and the root's readable state, and
737
- * records what was published so the next pass can compare against it.
738
- */
739
719
  #sync() {
740
720
  const combined = this.#combinedValue();
741
721
  if (this.hasValueTarget) {
@@ -755,31 +735,56 @@ var OtpController = class extends Controller {
755
735
  const previous = this.#published;
756
736
  const { value: combined } = this.#sync();
757
737
  if (previous?.value === combined) return;
738
+ const token = this.#moves.record();
758
739
  this.dispatch("change", { detail: { value: combined } });
740
+ if (!this.#moves.isLatest(token)) return;
759
741
  if (this.#isComplete()) {
760
742
  this.dispatch("complete", { detail: { value: combined } });
761
743
  }
762
744
  }
763
- /** Surfaces rejected input on every field and on the optional error target. */
764
745
  #showError() {
765
746
  const errorId = this.hasErrorTarget ? ensureId(this.errorTarget, "stimeo--otp-error") : null;
766
747
  for (const field of this.fieldTargets) {
767
748
  this.#lease(field, "aria-invalid", "true");
768
- if (!errorId) continue;
769
- this.#lease(field, "aria-errormessage", errorId);
770
- const described = field.getAttribute("aria-describedby");
771
- this.#lease(field, "aria-describedby", describedByWith(described, errorId));
749
+ if (errorId) this.#pointAtError(field, errorId);
772
750
  }
773
751
  if (this.hasErrorTarget) this.#errorHidden.write(this.errorTarget, null);
774
752
  this.dispatch("invalid", { detail: { pattern: this.#patternSource } });
775
753
  }
776
- /** Returns every error lease, restoring the authored error surface. */
754
+ #pointAtError(field, errorId) {
755
+ const earlier = readLease(field, LEASE_MARKERS["aria-errormessage"])?.written ?? null;
756
+ this.#lease(field, "aria-errormessage", errorId);
757
+ const described = field.getAttribute("aria-describedby");
758
+ this.#lease(field, "aria-describedby", describedByWith(described, errorId, earlier));
759
+ }
760
+ #followError() {
761
+ if (!this.#connected) return;
762
+ const reported = this.fieldTargets.filter(
763
+ (field) => readLease(field, LEASE_MARKERS["aria-invalid"]) !== null
764
+ );
765
+ if (reported.length === 0) {
766
+ this.#hideErrors();
767
+ return;
768
+ }
769
+ if (!this.hasErrorTarget) return;
770
+ const errorId = ensureId(this.errorTarget, "stimeo--otp-error");
771
+ for (const field of reported) this.#pointAtError(field, errorId);
772
+ this.#errorHidden.write(this.errorTarget, null);
773
+ }
777
774
  #clearError() {
778
775
  for (const field of this.fieldTargets) this.#returnLeases(field);
779
- this.#errorHidden.returnAll();
776
+ if (!this.#connected) {
777
+ this.#errorHidden.returnAll();
778
+ return;
779
+ }
780
+ this.#hideErrors();
781
+ }
782
+ #hideErrors() {
783
+ const [first, ...others] = this.errorTargets;
784
+ for (const other of others) this.#errorHidden.return(other);
785
+ if (first) this.#errorHidden.write(first, "");
780
786
  }
781
787
  };
782
788
 
783
789
  export { OtpController };
784
- //# sourceMappingURL=otp_controller.js.map
785
790
  //# sourceMappingURL=otp_controller.js.map