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,73 +1,223 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/password_reveal_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/before_cache_reset.ts
6
- var BeforeCacheReset = class _BeforeCacheReset {
7
- /** Every subscribed instance, iterated by the one shared document listener. */
8
- static #subscribers = /* @__PURE__ */ new Set();
9
- /** The shared listener; installed while at least one instance is subscribed. */
10
- static #onBeforeCache = () => {
11
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
- };
13
- #rewind;
14
- /** @param rewind - the pass that returns this controller's state to its initial form. */
15
- constructor(rewind) {
16
- this.#rewind = rewind;
17
- }
18
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
- activate() {
20
- const first = _BeforeCacheReset.#subscribers.size === 0;
21
- _BeforeCacheReset.#subscribers.add(this);
22
- if (first) {
23
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
13
+ function isAttributeValue(value) {
14
+ return value === null || typeof value === "string";
15
+ }
16
+ function readLeaseRecord(element, record) {
17
+ try {
18
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
19
+ if (!Array.isArray(parsed)) return void 0;
20
+ const [original, written, ...rest] = parsed;
21
+ const linked = rest[0] !== false;
22
+ const beneath = linked ? rest : rest.slice(1);
23
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
24
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
25
+ } catch {
26
+ return void 0;
27
+ }
28
+ }
29
+ var AttributeLease = class _AttributeLease {
30
+ static #registry = sharedRegistry(
31
+ "stimeo-ui.attribute-lease.registry.v1",
32
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
33
+ );
34
+ #attribute;
35
+ #record;
36
+ #base;
37
+ #held = /* @__PURE__ */ new Set();
38
+ constructor(attribute, owner, options = {}) {
39
+ this.#attribute = attribute;
40
+ this.#record = `data-${owner}-${attribute}-lease`;
41
+ this.#base = options.base === true;
42
+ const names = _AttributeLease.#registry.names;
43
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
44
+ }
45
+ write(element, value) {
46
+ const stack = this.#stack(element);
47
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
48
+ this.#held.add(element);
49
+ const shows = !this.#covered(element, stack, hold);
50
+ hold.written = value;
51
+ this.#mark(element, stack);
52
+ if (shows) this.#reflect(element, value);
53
+ }
54
+ return(element) {
55
+ const held = this.#held.delete(element);
56
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
57
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
58
+ const hold = this.#take(stack);
59
+ if (!hold) return false;
60
+ const carries = element.getAttribute(this.#attribute) === hold.written;
61
+ const owns = carries && !this.#covered(element, stack, hold);
62
+ const index = stack.indexOf(hold);
63
+ stack.splice(index, 1);
64
+ const above = stack[index];
65
+ if (above) above.linked &&= hold.linked;
66
+ this.#mark(element, stack);
67
+ if (!owns) return true;
68
+ const below = hold.linked ? stack[index - 1] : void 0;
69
+ this.#reflect(element, below ? below.written : hold.original);
70
+ return true;
71
+ }
72
+ returnAll() {
73
+ for (const element of Array.from(this.#held)) this.return(element);
74
+ }
75
+ #stack(element) {
76
+ const stacks = _AttributeLease.#registry.stacks;
77
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
78
+ stacks.set(element, byAttribute);
79
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
80
+ byAttribute.set(this.#attribute, stack);
81
+ return stack;
82
+ }
83
+ #seed(element) {
84
+ const found = [];
85
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
86
+ const read = readLeaseRecord(element, record);
87
+ if (read) found.push({ ...read, record });
24
88
  }
89
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
90
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
91
+ lease: null,
92
+ record,
93
+ written,
94
+ original,
95
+ linked: linked && index > 0
96
+ }));
97
+ }
98
+ #take(stack) {
99
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
100
+ if (hold) hold.lease = this;
101
+ return hold;
102
+ }
103
+ #acquire(element, stack) {
104
+ const current = element.getAttribute(this.#attribute);
105
+ const top = stack.at(-1);
106
+ const bottom = stack[0];
107
+ const over = top?.written === current ? top : void 0;
108
+ const from = this.#base ? bottom : over;
109
+ const hold = {
110
+ lease: this,
111
+ record: this.#record,
112
+ written: current,
113
+ original: from ? from.original : current,
114
+ linked: !this.#base && over !== void 0
115
+ };
116
+ if (this.#base && bottom) {
117
+ bottom.linked = true;
118
+ stack.unshift(hold);
119
+ } else stack.push(hold);
120
+ return hold;
121
+ }
122
+ #covered(element, stack, hold) {
123
+ const current = element.getAttribute(this.#attribute);
124
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
125
+ }
126
+ #mark(element, stack) {
127
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
128
+ stack.forEach((hold, index) => {
129
+ const names = stack.slice(0, index).map((under) => under.record);
130
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
131
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
132
+ records.set(
133
+ hold.record,
134
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
135
+ );
136
+ });
137
+ for (const [name, recorded] of records) {
138
+ if (element.getAttribute(name) === recorded) continue;
139
+ if (recorded === null) element.removeAttribute(name);
140
+ else element.setAttribute(name, recorded);
141
+ }
142
+ }
143
+ #reflect(element, value) {
144
+ if (element.getAttribute(this.#attribute) === value) return;
145
+ if (value === null) element.removeAttribute(this.#attribute);
146
+ else element.setAttribute(this.#attribute, value);
25
147
  }
26
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
- deactivate() {
28
- _BeforeCacheReset.#subscribers.delete(this);
29
- if (_BeforeCacheReset.#subscribers.size > 0) return;
30
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
148
+ };
149
+
150
+ var ListenerSet = class {
151
+ #abort = new AbortController();
152
+ add(target, type, handler, options) {
153
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
154
+ }
155
+ dispose() {
156
+ this.#abort.abort();
157
+ this.#abort = new AbortController();
158
+ }
159
+ };
160
+
161
+ function matchesNumberBounds(value, bounds) {
162
+ if (!Number.isFinite(value)) {
163
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
164
+ if (direction === null) return false;
165
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
166
+ }
167
+ if (bounds.min !== void 0 && value < bounds.min) return false;
168
+ if (bounds.max !== void 0 && value > bounds.max) return false;
169
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
170
+ if (bounds.integer && !Number.isInteger(value)) return false;
171
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
172
+ return true;
173
+ }
174
+
175
+ function readNumber(raw, fallback, bounds) {
176
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
177
+ }
178
+
179
+ var NumberValueReader = class {
180
+ #lastRejected = /* @__PURE__ */ new Map();
181
+ read(owner, name, raw, fallback, bounds) {
182
+ const resolved = readNumber(raw, fallback, bounds);
183
+ if (matchesNumberBounds(raw, bounds)) {
184
+ this.#lastRejected.delete(name);
185
+ return resolved;
186
+ }
187
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
188
+ const literal = owner.element.getAttribute(attribute);
189
+ if (literal === null) {
190
+ this.#lastRejected.delete(name);
191
+ return resolved;
192
+ }
193
+ if (this.#lastRejected.get(name) !== literal) {
194
+ this.#lastRejected.set(name, literal);
195
+ console.warn(
196
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
197
+ );
198
+ }
199
+ return resolved;
31
200
  }
32
201
  };
33
202
 
34
- // src/utils/safe_timeout.ts
35
203
  var TimerRegistry = class {
36
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
37
204
  ids = /* @__PURE__ */ new Set();
38
- /**
39
- * Cancels a single tracked timer.
40
- *
41
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
42
- * registry), so callers can clear defensively without guarding.
43
- */
44
205
  clear(id) {
45
206
  if (this.ids.delete(id)) {
46
207
  this.cancel(id);
47
208
  }
48
209
  }
49
- /**
50
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
51
- * to guarantee no timer outlives the element.
52
- */
53
210
  clearAll() {
54
211
  for (const id of this.ids) {
55
212
  this.cancel(id);
56
213
  }
57
214
  this.ids.clear();
58
215
  }
59
- /** Number of timers currently tracked (pending). */
60
216
  get size() {
61
217
  return this.ids.size;
62
218
  }
63
219
  };
64
220
  var SafeTimeout = class extends TimerRegistry {
65
- /**
66
- * Schedules `callback` after `delay` ms and returns the timer id.
67
- *
68
- * The id is removed from the registry automatically when the timeout fires,
69
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
70
- */
71
221
  set(callback, delay) {
72
222
  const id = this.schedule(() => {
73
223
  this.ids.delete(id);
@@ -84,16 +234,20 @@ var SafeTimeout = class extends TimerRegistry {
84
234
  }
85
235
  };
86
236
 
87
- // src/utils/state_regions.ts
88
- var StateRegions = class {
237
+ var StateRegions = class _StateRegions {
238
+ static #registry = sharedRegistry(
239
+ "stimeo-ui.state-regions.registry.v1",
240
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
241
+ );
89
242
  #whenTrue;
90
243
  #whenFalse;
91
- #taken = /* @__PURE__ */ new WeakSet();
92
- constructor(options) {
244
+ #record;
245
+ constructor(options, owner) {
93
246
  this.#whenTrue = options.whenTrue;
94
247
  this.#whenFalse = options.whenFalse ?? null;
248
+ this.#record = `data-${owner}-hidden-region`;
249
+ _StateRegions.#registry.names.add(this.#record);
95
250
  }
96
- /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
97
251
  reflect(host, isTrue) {
98
252
  const shown = this.#inside(host, this.#whenTrue());
99
253
  if (!this.#whenFalse) {
@@ -109,111 +263,232 @@ var StateRegions = class {
109
263
  for (const region of shown) this.#write(region, !isTrue);
110
264
  for (const region of hiddenSide) this.#write(region, isTrue);
111
265
  }
112
- /** Writes `hidden` where it moves, noting which regions it takes out of view. */
266
+ release(host) {
267
+ for (const region of [host, ...host.querySelectorAll("*")]) {
268
+ if (!(region instanceof HTMLElement)) continue;
269
+ const stack = this.#held(region);
270
+ const hold = stack && this.#take(stack);
271
+ if (!stack || !hold) continue;
272
+ const owns = region.hidden === hold.written && !this.#covered(region, stack, hold);
273
+ const index = stack.indexOf(hold);
274
+ stack.splice(index, 1);
275
+ const above = stack[index];
276
+ if (above) above.linked &&= hold.linked;
277
+ if (owns) {
278
+ const below = hold.linked ? stack[index - 1] : void 0;
279
+ if (below) {
280
+ if (region.hidden !== below.written) region.hidden = below.written;
281
+ } else if (region.getAttribute("hidden") !== hold.authored) {
282
+ if (hold.authored === null) region.removeAttribute("hidden");
283
+ else region.setAttribute("hidden", hold.authored);
284
+ }
285
+ }
286
+ this.#mark(region, stack);
287
+ }
288
+ }
113
289
  #write(region, hidden) {
114
- if (hidden) this.#taken.add(region);
115
- else this.#taken.delete(region);
116
- if (region.hidden !== hidden) region.hidden = hidden;
290
+ const stack = this.#stack(region);
291
+ const hold = this.#take(stack) ?? this.#acquire(region, stack);
292
+ const shows = !this.#covered(region, stack, hold);
293
+ hold.written = hidden;
294
+ if (shows && region.hidden !== hidden) region.hidden = hidden;
295
+ this.#mark(region, stack);
117
296
  }
118
- /** Returns a region this instance hid; one it never hid keeps what it carries. */
119
297
  #give(region) {
120
- if (this.#taken.delete(region)) region.hidden = false;
298
+ const stack = this.#held(region);
299
+ if (stack && this.#take(stack)?.written) this.#write(region, false);
300
+ }
301
+ #held(region) {
302
+ return _StateRegions.#registry.stacks.has(region) || region.hasAttribute(this.#record) ? this.#stack(region) : void 0;
303
+ }
304
+ #stack(region) {
305
+ const stacks = _StateRegions.#registry.stacks;
306
+ const stack = stacks.get(region) ?? this.#seed(region);
307
+ stacks.set(region, stack);
308
+ return stack;
309
+ }
310
+ #seed(region) {
311
+ const found = [];
312
+ for (const record of _StateRegions.#registry.names) {
313
+ const read = this.#recorded(region, record);
314
+ if (read) found.push({ ...read, record });
315
+ }
316
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
317
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, authored, written, linked }, index) => ({
318
+ instance: null,
319
+ record,
320
+ authored,
321
+ written,
322
+ linked: linked && index > 0
323
+ }));
324
+ }
325
+ #take(stack) {
326
+ const hold = stack.find((held) => held.instance === this) ?? stack.find((held) => held.instance === null && held.record === this.#record);
327
+ if (hold) hold.instance = this;
328
+ return hold;
329
+ }
330
+ #covered(region, stack, hold) {
331
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === region.hidden);
332
+ }
333
+ #acquire(region, stack) {
334
+ const top = stack.at(-1);
335
+ const over = top && region.hidden === top.written ? top : void 0;
336
+ const hold = {
337
+ instance: this,
338
+ record: this.#record,
339
+ authored: over ? over.authored : region.getAttribute("hidden"),
340
+ written: false,
341
+ linked: over !== void 0
342
+ };
343
+ stack.push(hold);
344
+ return hold;
345
+ }
346
+ #mark(region, stack) {
347
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
348
+ stack.forEach((hold, index) => {
349
+ const names = stack.slice(0, index).map((under) => under.record);
350
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
351
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
352
+ records.set(
353
+ hold.record,
354
+ region.getAttribute("hidden") === hold.authored && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.authored, hold.written, ...marker, ...beneath])
355
+ );
356
+ });
357
+ for (const [name, recorded] of records) {
358
+ if (region.getAttribute(name) === recorded) continue;
359
+ if (recorded === null) region.removeAttribute(name);
360
+ else region.setAttribute(name, recorded);
361
+ }
362
+ }
363
+ #recorded(region, name) {
364
+ try {
365
+ const parsed = JSON.parse(region.getAttribute(name) ?? "null");
366
+ if (!Array.isArray(parsed)) return null;
367
+ const [authored, written, ...rest] = parsed;
368
+ if (authored !== null && typeof authored !== "string") return null;
369
+ const linked = rest[0] !== false;
370
+ const beneath = linked ? rest : rest.slice(1);
371
+ if (!beneath.every((under) => typeof under === "string")) return null;
372
+ return typeof written === "boolean" ? { authored, written, linked, beneath } : null;
373
+ } catch {
374
+ return null;
375
+ }
121
376
  }
122
- /** The declared regions that sit within `host`, which may be the host itself. */
123
377
  #inside(host, regions) {
124
378
  return regions.filter((region) => host.contains(region));
125
379
  }
126
380
  };
127
381
 
128
- // src/controllers/password_reveal_controller.ts
129
382
  var MAX_DELAY = 2 ** 31 - 1;
130
- var PasswordRevealController = class extends Controller {
383
+ function maskCopyOf(type, input) {
384
+ const revealed = input.type === "text";
385
+ type.return(input);
386
+ if (revealed && input.type === "password") input.value = "";
387
+ }
388
+ var PasswordRevealController = class _PasswordRevealController extends Controller {
389
+ #numbers = new NumberValueReader();
131
390
  static targets = ["input", "toggle", "onLabel", "offLabel"];
132
391
  static values = {
133
392
  autoHide: { type: Number, default: 0 }
134
393
  };
394
+ static valueConstraints = {
395
+ autoHide: { finite: true, allowInfinity: "positive" }
396
+ };
135
397
  static actions = ["toggle"];
136
398
  static events = ["toggle"];
137
- /** Auto re-mask timer; torn down on disconnect. */
399
+ static #maskedRenders = sharedRegistry(
400
+ "stimeo-ui.password-reveal-render-mask.registry.v1",
401
+ () => /* @__PURE__ */ new WeakMap()
402
+ );
138
403
  #timers = new SafeTimeout();
139
- /**
140
- * Whether this controller is between `connect()` and `disconnect()`.
141
- *
142
- * Target callbacks outlive the controller: Stimulus stops the target observer
143
- * after `disconnect()`, so a field leaving after teardown still reaches
144
- * {@link PasswordRevealController.inputTargetDisconnected}. Arming from there
145
- * would put a timer back that nothing will clear. The element staying in the
146
- * document does not answer this — unloading the controller leaves it there.
147
- */
148
404
  #connected = false;
149
- /** Owns `hidden` on the label pair the toggle button carries. */
150
- #labels = new StateRegions({
151
- whenTrue: () => this.onLabelTargets,
152
- whenFalse: () => this.offLabelTargets
153
- });
154
- /** Masks the field before Turbo copies the page into its snapshot. */
155
- #beforeCache = new BeforeCacheReset(() => this.#rewindToMasked());
405
+ #labels = new StateRegions(
406
+ {
407
+ whenTrue: () => this.onLabelTargets,
408
+ whenFalse: () => this.offLabelTargets
409
+ },
410
+ this.identifier
411
+ );
412
+ #pressed = new AttributeLease("aria-pressed", this.identifier);
413
+ #type = new AttributeLease("type", this.identifier);
414
+ #listeners = new ListenerSet();
415
+ #lived = false;
156
416
  connect() {
157
417
  this.#connected = true;
418
+ if (!this.#lived) this.#maskCopy();
419
+ this.#lived = true;
158
420
  const visible = this.#isVisible;
159
421
  this.#reflect(visible);
160
- this.#beforeCache.activate();
422
+ this.#listeners.add(document, "turbo:before-cache", this.#rewindToMasked);
423
+ _PasswordRevealController.#maskRendersInto(this.element.ownerDocument, this.identifier);
161
424
  this.#arm(visible);
162
425
  }
163
426
  disconnect() {
164
427
  this.#connected = false;
165
428
  this.#timers.clearAll();
166
- this.#beforeCache.deactivate();
429
+ this.#listeners.dispose();
167
430
  }
168
- /** Re-derives the hooks and the re-mask for a field swapped in after connect. */
169
431
  inputTargetConnected() {
432
+ for (const input of this.inputTargets.slice(1)) this.#type.return(input);
170
433
  const visible = this.#connected && this.#isVisible;
171
434
  this.#reflect(visible);
172
435
  this.#arm(visible);
173
436
  }
174
- /**
175
- * Re-derives from whatever field is left rather than assuming none is. A swap
176
- * delivers this callback next to the arrival in either order, so a revealed
177
- * replacement that answered "masked" here would be described as hidden while
178
- * showing the password, and would carry no re-mask.
179
- *
180
- * Teardown is the one case with nothing to derive: target callbacks run after
181
- * `disconnect()`, so the field is still revealed and re-arming from it would
182
- * outlive the controller. A detached root is the signal to stand down.
183
- */
184
- inputTargetDisconnected() {
185
- const visible = this.#connected && this.element.isConnected && this.#isVisible;
437
+ inputTargetDisconnected(input) {
438
+ if (!this.inputTargets.includes(input)) this.#type.return(input);
439
+ if (!this.#connected) return;
440
+ const visible = this.#isVisible;
186
441
  this.#reflect(visible);
187
442
  this.#arm(visible);
188
443
  }
189
- /** Re-derives the pressed state for a button swapped in after connect. */
190
444
  toggleTargetConnected() {
191
445
  this.#reflect(this.#isVisible);
192
446
  }
193
- /** Describes the state to a revealed-side label that arrives after connect. */
447
+ toggleTargetDisconnected(toggle) {
448
+ if (!this.toggleTargets.includes(toggle)) {
449
+ this.#pressed.return(toggle);
450
+ this.#labels.release(toggle);
451
+ }
452
+ if (this.#connected) this.#reflect(this.#isVisible);
453
+ }
194
454
  onLabelTargetConnected() {
195
455
  this.#reflect(this.#isVisible);
196
456
  }
197
- /** Describes the state to a masked-side label that arrives after connect. */
198
457
  offLabelTargetConnected() {
199
458
  this.#reflect(this.#isVisible);
200
459
  }
201
- /** Toggles the input between masked and revealed. Bound via `data-action`. */
202
460
  toggle() {
203
461
  this.#setVisible(!this.#isVisible);
204
462
  }
205
- /** Whether the input is currently revealed (`type="text"`). */
206
463
  get #isVisible() {
207
464
  return this.hasInputTarget && this.inputTarget.type === "text";
208
465
  }
209
- /** Switches the masked/revealed state, preserving focus and caret. */
466
+ #maskCopy() {
467
+ if (this.hasInputTarget) maskCopyOf(this.#type, this.inputTarget);
468
+ }
469
+ static #maskRendersInto(document2, identifier) {
470
+ const masked = _PasswordRevealController.#maskedRenders.get(document2) ?? /* @__PURE__ */ new Set();
471
+ if (masked.has(identifier)) return;
472
+ masked.add(identifier);
473
+ _PasswordRevealController.#maskedRenders.set(document2, masked);
474
+ const type = new AttributeLease("type", identifier);
475
+ document2.addEventListener("turbo:before-render", (event) => {
476
+ const body = event.detail?.newBody;
477
+ if (!(body instanceof Element)) return;
478
+ for (const input of body.querySelectorAll(
479
+ `input[data-${identifier}-type-lease]`
480
+ )) {
481
+ if (input.type === "text") maskCopyOf(type, input);
482
+ }
483
+ });
484
+ }
210
485
  #setVisible(visible) {
211
486
  if (!this.hasInputTarget) return;
212
487
  const input = this.inputTarget;
213
488
  const restoreInputFocus = document.activeElement === input;
214
489
  const selectionStart = input.selectionStart;
215
490
  const selectionEnd = input.selectionEnd;
216
- input.type = visible ? "text" : "password";
491
+ this.#type.write(input, visible ? "text" : "password");
217
492
  if (restoreInputFocus) {
218
493
  input.focus();
219
494
  if (selectionStart !== null && selectionEnd !== null) {
@@ -227,7 +502,6 @@ var PasswordRevealController = class extends Controller {
227
502
  this.dispatch("toggle", { detail: { visible } });
228
503
  this.#arm(visible);
229
504
  }
230
- /** Schedules the auto re-mask for a revealed field, replacing any pending one. */
231
505
  #arm(visible) {
232
506
  this.#timers.clearAll();
233
507
  const delay = this.#autoHideDelay;
@@ -235,39 +509,33 @@ var PasswordRevealController = class extends Controller {
235
509
  this.#timers.set(() => this.#setVisible(false), delay);
236
510
  }
237
511
  }
238
- /**
239
- * The auto re-mask delay, held to what `setTimeout` can carry. Past that limit
240
- * a delay folds to zero, turning "keep it showing" into "hide it at once" —
241
- * the opposite of what the declaration asked for. A value that is no delay at
242
- * all stays out of the positive range {@link PasswordRevealController.#arm}
243
- * requires, so it schedules nothing.
244
- *
245
- * @stimeoRuntimeOnly `autoHide` is the delay of the one re-masking timer it arms.
246
- */
247
512
  get #autoHideDelay() {
248
- return Math.min(this.autoHideValue, MAX_DELAY);
249
- }
250
- /**
251
- * Returns the field to masked before Turbo copies the page. Silent and
252
- * focus-free: the page is about to be frozen, so there is no one to tell and
253
- * nowhere for focus to go.
254
- */
255
- #rewindToMasked() {
513
+ return Math.min(this.#safeAutoHide, MAX_DELAY);
514
+ }
515
+ #rewindToMasked = () => {
256
516
  this.#timers.clearAll();
257
517
  if (!this.hasInputTarget) return;
258
- this.inputTarget.type = "password";
518
+ this.#type.write(this.inputTarget, "password");
259
519
  this.#reflect(false);
260
- }
261
- /** Reflects the visible state onto `aria-pressed`, `data-state` and the labels. */
520
+ };
262
521
  #reflect(visible) {
263
522
  if (this.hasToggleTarget) {
264
- this.toggleTarget.setAttribute("aria-pressed", visible ? "true" : "false");
265
- this.#labels.reflect(this.toggleTarget, visible);
523
+ const toggle = this.toggleTarget;
524
+ this.#pressed.write(toggle, visible ? "true" : "false");
525
+ this.#labels.reflect(toggle, visible);
266
526
  }
267
527
  this.element.setAttribute("data-state", visible ? "visible" : "hidden");
268
528
  }
529
+ get #safeAutoHide() {
530
+ return this.#numbers.read(
531
+ this,
532
+ "autoHide",
533
+ this.autoHideValue,
534
+ _PasswordRevealController.values.autoHide.default,
535
+ _PasswordRevealController.valueConstraints.autoHide
536
+ );
537
+ }
269
538
  };
270
539
 
271
540
  export { PasswordRevealController };
272
- //# sourceMappingURL=password_reveal_controller.js.map
273
541
  //# sourceMappingURL=password_reveal_controller.js.map