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,89 +1,183 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/rating_controller.ts
3
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
4
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
5
+ function stateReasonFor(event) {
6
+ if (!event) return "api";
7
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
8
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
9
+ return "user";
10
+ }
11
+
12
+ function actionSource(source) {
13
+ if (source instanceof HTMLElement) {
14
+ return { event: null, host: source, origin: source, reason: "api" };
15
+ }
16
+ const event = source ?? null;
17
+ return {
18
+ event,
19
+ host: event?.currentTarget instanceof Element ? event.currentTarget : null,
20
+ origin: event?.target instanceof Element ? event.target : null,
21
+ reason: stateReasonFor(event)
22
+ };
23
+ }
4
24
 
5
- // src/utils/logical_scroll.ts
6
25
  function isRtl(element) {
7
26
  return window.getComputedStyle(element).direction === "rtl";
8
27
  }
9
28
 
10
- // src/utils/arrow_step.ts
11
29
  function isReservedArrowChord(event, allow = []) {
12
30
  if (!event.key.startsWith("Arrow")) return false;
13
31
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
32
  }
15
33
 
16
- // src/utils/attribute_lease.ts
17
- var AttributeLease = class {
34
+ function sharedRegistry(key, create) {
35
+ const symbol = Symbol.for(key);
36
+ const scope = globalThis;
37
+ const existing = scope[symbol];
38
+ if (existing !== void 0) return existing;
39
+ const registry = create();
40
+ Object.defineProperty(globalThis, symbol, { value: registry });
41
+ return registry;
42
+ }
43
+
44
+ function isAttributeValue(value) {
45
+ return value === null || typeof value === "string";
46
+ }
47
+ function readLeaseRecord(element, record) {
48
+ try {
49
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
50
+ if (!Array.isArray(parsed)) return void 0;
51
+ const [original, written, ...rest] = parsed;
52
+ const linked = rest[0] !== false;
53
+ const beneath = linked ? rest : rest.slice(1);
54
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
55
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
56
+ } catch {
57
+ return void 0;
58
+ }
59
+ }
60
+ var AttributeLease = class _AttributeLease {
61
+ static #registry = sharedRegistry(
62
+ "stimeo-ui.attribute-lease.registry.v1",
63
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
64
+ );
18
65
  #attribute;
19
- #records = /* @__PURE__ */ new Map();
20
- /** @param attribute - The attribute whose temporary values this lease owns. */
21
- constructor(attribute) {
66
+ #record;
67
+ #base;
68
+ #held = /* @__PURE__ */ new Set();
69
+ constructor(attribute, owner, options = {}) {
22
70
  this.#attribute = attribute;
71
+ this.#record = `data-${owner}-${attribute}-lease`;
72
+ this.#base = options.base === true;
73
+ const names = _AttributeLease.#registry.names;
74
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
23
75
  }
24
- /** Writes or removes the leased attribute while preserving its authored value. */
25
76
  write(element, value) {
26
- const existing = this.#records.get(element);
27
- if (existing) {
28
- existing.written = value;
29
- } else {
30
- this.#records.set(element, {
31
- original: element.getAttribute(this.#attribute),
32
- written: value
33
- });
34
- }
35
- this.#reflect(element, value);
77
+ const stack = this.#stack(element);
78
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
79
+ this.#held.add(element);
80
+ const shows = !this.#covered(element, stack, hold);
81
+ hold.written = value;
82
+ this.#mark(element, stack);
83
+ if (shows) this.#reflect(element, value);
36
84
  }
37
- /** Returns one lease without overwriting a value subsequently authored by a consumer. */
38
85
  return(element) {
39
- const record = this.#records.get(element);
40
- if (!record) return;
41
- this.#records.delete(element);
42
- const stillOwned = element.getAttribute(this.#attribute) === record.written;
43
- if (stillOwned) this.#reflect(element, record.original);
86
+ const held = this.#held.delete(element);
87
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
88
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
89
+ const hold = this.#take(stack);
90
+ if (!hold) return false;
91
+ const carries = element.getAttribute(this.#attribute) === hold.written;
92
+ const owns = carries && !this.#covered(element, stack, hold);
93
+ const index = stack.indexOf(hold);
94
+ stack.splice(index, 1);
95
+ const above = stack[index];
96
+ if (above) above.linked &&= hold.linked;
97
+ this.#mark(element, stack);
98
+ if (!owns) return true;
99
+ const below = hold.linked ? stack[index - 1] : void 0;
100
+ this.#reflect(element, below ? below.written : hold.original);
101
+ return true;
102
+ }
103
+ returnAll() {
104
+ for (const element of Array.from(this.#held)) this.return(element);
105
+ }
106
+ #stack(element) {
107
+ const stacks = _AttributeLease.#registry.stacks;
108
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
109
+ stacks.set(element, byAttribute);
110
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
111
+ byAttribute.set(this.#attribute, stack);
112
+ return stack;
113
+ }
114
+ #seed(element) {
115
+ const found = [];
116
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
117
+ const read = readLeaseRecord(element, record);
118
+ if (read) found.push({ ...read, record });
119
+ }
120
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
121
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
122
+ lease: null,
123
+ record,
124
+ written,
125
+ original,
126
+ linked: linked && index > 0
127
+ }));
128
+ }
129
+ #take(stack) {
130
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
131
+ if (hold) hold.lease = this;
132
+ return hold;
133
+ }
134
+ #acquire(element, stack) {
135
+ const current = element.getAttribute(this.#attribute);
136
+ const top = stack.at(-1);
137
+ const bottom = stack[0];
138
+ const over = top?.written === current ? top : void 0;
139
+ const from = this.#base ? bottom : over;
140
+ const hold = {
141
+ lease: this,
142
+ record: this.#record,
143
+ written: current,
144
+ original: from ? from.original : current,
145
+ linked: !this.#base && over !== void 0
146
+ };
147
+ if (this.#base && bottom) {
148
+ bottom.linked = true;
149
+ stack.unshift(hold);
150
+ } else stack.push(hold);
151
+ return hold;
152
+ }
153
+ #covered(element, stack, hold) {
154
+ const current = element.getAttribute(this.#attribute);
155
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
156
+ }
157
+ #mark(element, stack) {
158
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
159
+ stack.forEach((hold, index) => {
160
+ const names = stack.slice(0, index).map((under) => under.record);
161
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
162
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
163
+ records.set(
164
+ hold.record,
165
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
166
+ );
167
+ });
168
+ for (const [name, recorded] of records) {
169
+ if (element.getAttribute(name) === recorded) continue;
170
+ if (recorded === null) element.removeAttribute(name);
171
+ else element.setAttribute(name, recorded);
172
+ }
44
173
  }
45
- /** Reflects only a real value transition, avoiding self-triggered mutation work. */
46
174
  #reflect(element, value) {
47
175
  if (element.getAttribute(this.#attribute) === value) return;
48
176
  if (value === null) element.removeAttribute(this.#attribute);
49
177
  else element.setAttribute(this.#attribute, value);
50
178
  }
51
- /** Returns every outstanding lease using the same ownership check as {@link return}. */
52
- returnAll() {
53
- for (const element of Array.from(this.#records.keys())) this.return(element);
54
- }
55
- };
56
-
57
- // src/utils/before_cache_reset.ts
58
- var BeforeCacheReset = class _BeforeCacheReset {
59
- /** Every subscribed instance, iterated by the one shared document listener. */
60
- static #subscribers = /* @__PURE__ */ new Set();
61
- /** The shared listener; installed while at least one instance is subscribed. */
62
- static #onBeforeCache = () => {
63
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
64
- };
65
- #rewind;
66
- /** @param rewind - the pass that returns this controller's state to its initial form. */
67
- constructor(rewind) {
68
- this.#rewind = rewind;
69
- }
70
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
71
- activate() {
72
- const first = _BeforeCacheReset.#subscribers.size === 0;
73
- _BeforeCacheReset.#subscribers.add(this);
74
- if (first) {
75
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
76
- }
77
- }
78
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
79
- deactivate() {
80
- _BeforeCacheReset.#subscribers.delete(this);
81
- if (_BeforeCacheReset.#subscribers.size > 0) return;
82
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
83
- }
84
179
  };
85
180
 
86
- // src/utils/field_mirror.ts
87
181
  function writeField(field, value) {
88
182
  if (field.value === value) return false;
89
183
  field.value = value;
@@ -93,27 +187,33 @@ function commitField(target) {
93
187
  target.dispatchEvent(new Event("change", { bubbles: true }));
94
188
  }
95
189
 
96
- // src/utils/microtask_coalescer.ts
190
+ var ListenerSet = class {
191
+ #abort = new AbortController();
192
+ add(target, type, handler, options) {
193
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
194
+ }
195
+ dispose() {
196
+ this.#abort.abort();
197
+ this.#abort = new AbortController();
198
+ }
199
+ };
200
+
97
201
  var MicrotaskCoalescer = class {
98
202
  #run;
99
203
  #queued = false;
100
204
  #active = false;
101
205
  #generation = 0;
102
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
103
206
  constructor(run) {
104
207
  this.#run = run;
105
208
  }
106
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
107
209
  activate() {
108
210
  this.#active = true;
109
211
  }
110
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
111
212
  cancel() {
112
213
  this.#active = false;
113
214
  this.#queued = false;
114
215
  this.#generation += 1;
115
216
  }
116
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
117
217
  schedule() {
118
218
  if (!this.#active || this.#queued) return;
119
219
  this.#queued = true;
@@ -126,31 +226,94 @@ var MicrotaskCoalescer = class {
126
226
  }
127
227
  };
128
228
 
129
- // src/utils/roving_tabindex.ts
229
+ var MorphRenderWatcher = class {
230
+ #listeners = new ListenerSet();
231
+ #pass;
232
+ #element = null;
233
+ #onMorph = () => this.#pass.schedule();
234
+ constructor(run) {
235
+ this.#pass = new MicrotaskCoalescer(run);
236
+ }
237
+ observe(element) {
238
+ if (this.#element === element) return;
239
+ this.disconnect();
240
+ this.#element = element;
241
+ this.#pass.activate();
242
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
243
+ }
244
+ schedule() {
245
+ this.#pass.schedule();
246
+ }
247
+ disconnect() {
248
+ this.#listeners.dispose();
249
+ this.#pass.cancel();
250
+ this.#element = null;
251
+ }
252
+ };
253
+
254
+ var MoveCounter = class {
255
+ #sequence = 0;
256
+ record() {
257
+ this.#sequence += 1;
258
+ return this.#sequence;
259
+ }
260
+ isLatest(token) {
261
+ return token === this.#sequence;
262
+ }
263
+ };
264
+
265
+ var NUMBER_BOUNDS = {
266
+ finite: { finite: true }};
267
+ function matchesNumberBounds(value, bounds) {
268
+ if (!Number.isFinite(value)) {
269
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
270
+ if (direction === null) return false;
271
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
272
+ }
273
+ if (bounds.min !== void 0 && value < bounds.min) return false;
274
+ if (bounds.max !== void 0 && value > bounds.max) return false;
275
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
276
+ if (bounds.integer && !Number.isInteger(value)) return false;
277
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
278
+ return true;
279
+ }
280
+
281
+ function readNumber(raw, fallback, bounds) {
282
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
283
+ }
284
+
285
+ var NumberValueReader = class {
286
+ #lastRejected = /* @__PURE__ */ new Map();
287
+ read(owner, name, raw, fallback, bounds) {
288
+ const resolved = readNumber(raw, fallback, bounds);
289
+ if (matchesNumberBounds(raw, bounds)) {
290
+ this.#lastRejected.delete(name);
291
+ return resolved;
292
+ }
293
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
294
+ const literal = owner.element.getAttribute(attribute);
295
+ if (literal === null) {
296
+ this.#lastRejected.delete(name);
297
+ return resolved;
298
+ }
299
+ if (this.#lastRejected.get(name) !== literal) {
300
+ this.#lastRejected.set(name, literal);
301
+ console.warn(
302
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
303
+ );
304
+ }
305
+ return resolved;
306
+ }
307
+ };
308
+
130
309
  var RovingTabindex = class {
131
- /** Returns the current ordered item elements; called on every operation. */
132
310
  #getItems;
133
- /**
134
- * @param getItems - Returns the current ordered item elements. Called on every
135
- * operation so the live target list is always used.
136
- */
137
311
  constructor(getItems) {
138
312
  this.#getItems = getItems;
139
313
  }
140
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
141
314
  get activeIndex() {
142
315
  return this.#getItems().findIndex((item) => item.tabIndex === 0);
143
316
  }
144
- /**
145
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
146
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
147
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
148
- * "nothing is currently tabbable".
149
- *
150
- * @param index - Position of the item to make tabbable.
151
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
152
- * and `items` to reuse an event-scoped collection snapshot.
153
- */
154
317
  setActive(index, options = {}) {
155
318
  const { focus = false } = options;
156
319
  const items = options.items ?? this.#getItems();
@@ -161,130 +324,212 @@ var RovingTabindex = class {
161
324
  }
162
325
  };
163
326
 
164
- // src/utils/tabindex_loan.ts
165
- var TabindexLoan = class {
327
+ function queryOpenDescendants(root, selector) {
328
+ const matches = [];
329
+ const roots = [root];
330
+ for (const scope of roots) {
331
+ matches.push(...scope.querySelectorAll(selector));
332
+ if (scope.nodeType === Node.ELEMENT_NODE) {
333
+ const shadow = scope.shadowRoot;
334
+ if (shadow) roots.push(shadow);
335
+ }
336
+ for (const element of scope.querySelectorAll("*")) {
337
+ if (element.shadowRoot) roots.push(element.shadowRoot);
338
+ }
339
+ }
340
+ return matches;
341
+ }
342
+
343
+ var TabindexLoan = class _TabindexLoan {
344
+ static #registry = sharedRegistry(
345
+ "stimeo-ui.tabindex-loan.registry.v1",
346
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
347
+ );
166
348
  #value;
349
+ #record;
167
350
  #lent = /* @__PURE__ */ new Set();
168
- /** Returns live loans before Turbo can copy them into its page snapshot. */
169
- #beforeCache = new BeforeCacheReset(() => this.returnAll());
170
- /**
171
- * @param value - the `tabindex` to lend. `"-1"` (the default) is
172
- * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
173
- * which a scroll region with no focusable content of its own needs.
174
- */
175
- constructor(value = "-1") {
351
+ constructor(value, owner) {
176
352
  this.#value = value;
353
+ this.#record = `data-${owner}-tabindex-loan`;
354
+ _TabindexLoan.#registry.names.add(this.#record);
177
355
  }
178
- /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
179
356
  lend(element) {
180
- if (element.hasAttribute("tabindex")) return;
181
- element.setAttribute("tabindex", this.#value);
357
+ const stack = this.#stack(element);
358
+ const current = element.getAttribute("tabindex");
359
+ const held = stack.find((hold) => hold.loan === this) ?? stack.find(
360
+ (hold) => hold.loan === null && hold.record === this.#record && hold.value === this.#value
361
+ );
362
+ const linked = stack.at(-1)?.value === current;
363
+ if (!held && current !== null && !linked) return;
182
364
  this.#lent.add(element);
183
- this.#beforeCache.activate();
365
+ if (held) held.loan = this;
366
+ else stack.push({ loan: this, record: this.#record, value: this.#value, linked });
367
+ this.#mark(element, stack);
368
+ if (current === null || !held && current !== this.#value) {
369
+ element.setAttribute("tabindex", this.#value);
370
+ }
184
371
  }
185
- /** Takes back every loan whose value is still the one that was lent. */
186
372
  returnAll() {
187
373
  for (const element of this.#lent) {
188
- if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
374
+ const stack = this.#stack(element);
375
+ const hold = stack.find((held) => held.loan === this);
376
+ if (hold) this.#release(element, stack, hold);
189
377
  }
190
378
  this.#lent.clear();
191
- this.#beforeCache.deactivate();
379
+ }
380
+ reclaim(element) {
381
+ if (element.getAttribute(this.#record) !== this.#value) return;
382
+ const stack = this.#stack(element);
383
+ const hold = stack.find((held) => held.loan === null && held.record === this.#record);
384
+ if (hold && !element.matches(":focus")) this.#release(element, stack, hold);
385
+ }
386
+ reclaimWithin(root) {
387
+ for (const element of queryOpenDescendants(root, `[${this.#record}]`)) {
388
+ this.reclaim(element);
389
+ }
390
+ }
391
+ #stack(element) {
392
+ const stacks = _TabindexLoan.#registry.stacks;
393
+ const stack = stacks.get(element) ?? this.#seed(element);
394
+ stacks.set(element, stack);
395
+ return stack;
396
+ }
397
+ #seed(element) {
398
+ const current = element.getAttribute("tabindex");
399
+ const holds = [];
400
+ for (const record of _TabindexLoan.#registry.names) {
401
+ const value = element.getAttribute(record);
402
+ if (value !== null) holds.push({ loan: null, record, value, linked: true });
403
+ }
404
+ holds.sort((one, other) => Number(one.value === current) - Number(other.value === current));
405
+ for (const [index, hold] of holds.entries()) hold.linked = index > 0;
406
+ return holds;
407
+ }
408
+ #release(element, stack, hold) {
409
+ const current = element.getAttribute("tabindex");
410
+ const index = stack.indexOf(hold);
411
+ const owns = current === hold.value && !stack.slice(index + 1).some((above2) => above2.value === current);
412
+ stack.splice(index, 1);
413
+ const above = stack[index];
414
+ if (above) above.linked &&= hold.linked;
415
+ this.#mark(element, stack);
416
+ if (!owns) return;
417
+ const below = hold.linked ? stack[index - 1] : void 0;
418
+ if (!below) element.removeAttribute("tabindex");
419
+ else if (below.value !== current) element.setAttribute("tabindex", below.value);
420
+ }
421
+ #mark(element, stack) {
422
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
423
+ for (const hold of stack) records.set(hold.record, hold.value);
424
+ for (const [name, recorded] of records) {
425
+ if (element.getAttribute(name) === recorded) continue;
426
+ if (recorded === null) element.removeAttribute(name);
427
+ else element.setAttribute(name, recorded);
428
+ }
192
429
  }
193
430
  };
194
431
 
195
- // src/controllers/rating_controller.ts
196
- var RatingController = class extends Controller {
432
+ function targetSelector(identifier, name) {
433
+ return `[data-${identifier}-target~="${name}"]`;
434
+ }
435
+
436
+ var RatingController = class _RatingController extends Controller {
437
+ #numbers = new NumberValueReader();
438
+ #moves = new MoveCounter();
439
+ #move = 0;
197
440
  static targets = ["symbol", "field"];
198
441
  static values = {
199
442
  value: { type: Number, default: 0 },
200
443
  clearable: { type: Boolean, default: true },
201
444
  readonly: { type: Boolean, default: false }
202
445
  };
446
+ static valueConstraints = {
447
+ value: NUMBER_BOUNDS.finite
448
+ };
203
449
  static actions = ["endPreview", "onKeydown", "preview", "select"];
204
450
  static events = ["change", "reconcile"];
205
451
  #roving = new RovingTabindex(() => this.symbolTargets);
206
- #rootRole = new AttributeLease("role");
207
- #symbolRole = new AttributeLease("role");
208
- #symbolAriaHidden = new AttributeLease("aria-hidden");
209
- #rootTabindex = new TabindexLoan();
210
- #repaint = new MicrotaskCoalescer(() => this.#reconcileScale());
211
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
452
+ #rootRole = new AttributeLease("role", this.identifier);
453
+ #symbolRole = new AttributeLease("role", this.identifier);
454
+ #symbolAriaHidden = new AttributeLease("aria-hidden", this.identifier);
455
+ #rootTabindex = new TabindexLoan("-1", this.identifier);
456
+ #repaint = new MorphRenderWatcher(() => this.#reconcileScale());
212
457
  #connected = false;
213
458
  #rescuedFocus = false;
214
- /** The rating shown last, which the next move is measured from. */
215
459
  #shown = 0;
216
- /** Reflects declarative state without announcing an initial user change. */
217
460
  connect() {
218
- this.#repaint.activate();
219
- this.#beforeCache.activate();
220
- this.#shown = this.#normalize(this.valueValue);
461
+ this.#rootTabindex.reclaim(this.element);
462
+ this.#repaint.observe(this.element);
463
+ this.#shown = this.#normalize(this.#safeValue);
221
464
  this.#apply(this.#shown, { focus: false });
222
465
  this.#connected = true;
223
466
  }
224
- /** Drops a queued reconciliation and hands every borrowed attribute back. */
225
467
  disconnect() {
226
468
  this.#connected = false;
227
- this.#repaint.cancel();
228
- this.#beforeCache.deactivate();
469
+ this.#repaint.disconnect();
229
470
  this.#releaseReadonly();
230
471
  }
231
- /** Removes a runtime-added symbol's authored Tab stop before the batch repaint. */
232
472
  symbolTargetConnected(symbol) {
233
473
  if (this.#connected === false) return;
234
474
  symbol.tabIndex = -1;
235
475
  this.#repaint.schedule();
236
476
  }
237
- /** Releases readonly ownership and reconciles the remaining DOM-ordered scale. */
238
477
  symbolTargetDisconnected(symbol) {
239
478
  this.#symbolRole.return(symbol);
240
479
  this.#symbolAriaHidden.return(symbol);
241
480
  this.#repaint.schedule();
242
481
  }
243
- /** Reflects into a hidden field added or replaced after connection. */
244
482
  fieldTargetConnected() {
245
483
  this.#repaint.schedule();
246
484
  }
247
- /** Reconciles after a hidden field is removed or replaced. */
248
485
  fieldTargetDisconnected() {
249
486
  this.#repaint.schedule();
250
487
  }
251
- /** Repaints when application code or a Turbo morph changes `value`. */
252
488
  valueValueChanged() {
253
489
  this.#repaint.schedule();
254
490
  }
255
- /** Repaints when application code changes whether value 0 is permitted. */
256
491
  clearableValueChanged() {
257
492
  this.#repaint.schedule();
258
493
  }
259
- /** Repaints when application code enters or leaves the readonly snapshot. */
260
494
  readonlyValueChanged() {
261
495
  this.#repaint.schedule();
262
496
  }
263
- /** Selects or clears the clicked symbol. Bound via `data-action` (click). */
264
- select(event) {
497
+ select(source) {
265
498
  if (this.readonlyValue) return;
266
- const ordinal = this.#symbolOrdinal(event.currentTarget);
499
+ const { event, host, origin, reason } = actionSource(source);
500
+ const symbol = host?.closest(targetSelector(this.identifier, "symbol"));
501
+ if (!symbol || !this.symbolTargets.includes(symbol)) return;
502
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
503
+ return;
504
+ const moveFocus = event !== null || this.element.contains(document.activeElement);
505
+ const ordinal = this.#symbolOrdinal(symbol);
267
506
  if (ordinal === null) return;
268
- const current = this.#normalize(this.valueValue);
269
- this.#commit(this.clearableValue && ordinal === current ? 0 : ordinal, {
270
- focus: ordinal === current
271
- });
272
- }
273
- /** Previews a fill range on hover or focus without committing it. */
274
- preview(event) {
507
+ const current = this.#normalize(this.#safeValue);
508
+ this.#commit(
509
+ this.clearableValue && ordinal === current ? 0 : ordinal,
510
+ {
511
+ focus: ordinal === current && moveFocus
512
+ },
513
+ reason
514
+ );
515
+ }
516
+ preview(source) {
275
517
  if (this.readonlyValue) return;
276
- const ordinal = this.#symbolOrdinal(event.currentTarget);
518
+ const { host, origin } = actionSource(source);
519
+ const symbol = host?.closest(targetSelector(this.identifier, "symbol"));
520
+ if (!symbol || !this.symbolTargets.includes(symbol)) return;
521
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
522
+ return;
523
+ const ordinal = this.#symbolOrdinal(symbol);
277
524
  if (ordinal !== null) this.#setFillRange(ordinal);
278
525
  }
279
- /** Restores the fill range after hover or focus leaves a symbol. */
280
526
  endPreview() {
281
527
  if (this.readonlyValue) return;
282
- this.#setFillRange(this.#normalize(this.valueValue));
528
+ this.#setFillRange(this.#normalize(this.#safeValue));
283
529
  }
284
- /** Arrow/Home/End/Space/Delete keyboard control, clamped without wrapping. */
285
530
  onKeydown(event) {
286
531
  if (event.defaultPrevented || isReservedArrowChord(event) || this.readonlyValue) return;
287
- const current = this.#normalize(this.valueValue);
532
+ const current = this.#normalize(this.#safeValue);
288
533
  let next = null;
289
534
  const rtl = isRtl(this.element);
290
535
  switch (event.key) {
@@ -318,69 +563,54 @@ var RatingController = class extends Controller {
318
563
  event.preventDefault();
319
564
  this.#commit(next, { focus: true });
320
565
  }
321
- /**
322
- * Repaints one settled target/Value mutation batch and reports a rating shown
323
- * that moved from the one shown before it.
324
- *
325
- * @stimeoRenderRoot
326
- */
327
566
  #reconcileScale() {
328
- const value = this.#normalize(this.valueValue);
567
+ const value = this.#normalize(this.#safeValue);
329
568
  const previous = this.#shown;
330
569
  this.#shown = value;
570
+ const move = this.#move;
331
571
  this.#apply(value, { focus: false });
572
+ if (!this.#moves.isLatest(move)) return;
332
573
  if (value !== previous) this.dispatch("reconcile", { detail: { value } });
333
574
  }
334
- /**
335
- * Applies one user operation and emits only when the rating shown changes.
336
- *
337
- * The user's value is written into `value` before anything reports it, so a
338
- * listener of the field's native `change` already reads it there, and the pass
339
- * that the write starts finds nothing left to report.
340
- */
341
- #commit(raw, { focus }) {
575
+ #commit(raw, { focus }, reason = "user") {
342
576
  const previous = this.#shown;
343
577
  const value = this.#normalize(raw);
344
578
  if (!Object.is(this.valueValue, value)) this.valueValue = value;
345
579
  this.#shown = value;
346
- this.#apply(value, { focus, notify: true });
347
- if (value !== previous) this.dispatch("change", { detail: { value } });
348
- }
349
- /** Synchronizes ARIA, roving focus, form state, and the visual fill hook. */
350
- #apply(value, { focus, notify = false }) {
580
+ if (value !== previous) this.#move = this.#moves.record();
581
+ const move = this.#move;
582
+ const field = this.#apply(value, { focus });
583
+ if (!this.#moves.isLatest(move)) return;
584
+ if (field) commitField(field);
585
+ if (!this.#moves.isLatest(move)) return;
586
+ if (value !== previous) this.dispatch("change", { detail: { value, reason } });
587
+ }
588
+ #apply(value, { focus }) {
589
+ const move = this.#move;
351
590
  this.symbolTargets.forEach((symbol, index) => {
352
591
  symbol.setAttribute("aria-checked", value > 0 && index + 1 === value ? "true" : "false");
353
592
  });
593
+ const field = this.hasFieldTarget ? this.fieldTarget : null;
594
+ const moved = field && writeField(field, String(value));
595
+ this.#setFillRange(value);
354
596
  if (this.readonlyValue) {
355
597
  this.#applyReadonly();
356
598
  } else {
357
599
  const returning = this.#releaseReadonly();
358
600
  this.#roving.setActive(value > 0 ? value - 1 : 0, { focus: focus || returning });
359
601
  }
360
- this.#mirrorField(value, notify);
361
- this.#setFillRange(value);
362
- }
363
- /** Mirrors `value` into the optional form field, reporting only a user's move. */
364
- #mirrorField(value, notify) {
365
- if (!this.hasFieldTarget) return;
366
- if (writeField(this.fieldTarget, String(value)) && notify) commitField(this.fieldTarget);
602
+ if (this.#moves.isLatest(move)) this.#setFillRange(value);
603
+ return moved ? field : null;
367
604
  }
368
- /** Marks the first `range` symbols with the consumer-owned fill hook. */
369
605
  #setFillRange(range) {
370
606
  this.symbolTargets.forEach((symbol, index) => {
371
607
  symbol.toggleAttribute("data-rating-hover", range > 0 && index < range);
372
608
  });
373
609
  }
374
- /**
375
- * Temporarily turns the radiogroup into a non-interactive image snapshot.
376
- *
377
- * Each lease is returned before it is taken again, so a value the consumer
378
- * wrote while readonly becomes the value the lease restores on release. The
379
- * return is a no-op on an attribute still holding this controller's own
380
- * write, which is the ordinary case.
381
- */
382
610
  #applyReadonly() {
611
+ const move = this.#move;
383
612
  this.#rescueFocus();
613
+ if (!this.#moves.isLatest(move)) return;
384
614
  this.#rootRole.return(this.element);
385
615
  this.#rootRole.write(this.element, "img");
386
616
  for (const symbol of this.symbolTargets) {
@@ -391,69 +621,50 @@ var RatingController = class extends Controller {
391
621
  }
392
622
  this.#roving.setActive(-1);
393
623
  }
394
- /**
395
- * Hands every readonly borrowing back before Turbo clones the page.
396
- *
397
- * The snapshot is taken while the controller is still connected, so an
398
- * element left as `role="img"` with hidden symbols is what a restored page
399
- * connects against — and that markup would be read as the authored one,
400
- * leaving no way back to the radiogroup. Rewinding first keeps the cached
401
- * copy identical to what the consumer wrote.
402
- */
403
- #rewindForCache() {
404
- this.#releaseReadonly();
405
- const value = this.#normalize(this.valueValue);
406
- this.#roving.setActive(value > 0 ? value - 1 : 0);
407
- }
408
- /**
409
- * Lands focus on the root before the symbols leave the accessibility tree.
410
- *
411
- * A symbol holding focus when readonly begins would keep it while losing its
412
- * role and gaining `aria-hidden`, stranding the user on a node no longer in
413
- * the tree. The root is the one element that survives the transition named:
414
- * it carries the consumer's accessible name under `role="img"`.
415
- */
416
624
  #rescueFocus() {
417
625
  const active = document.activeElement;
418
626
  if (!(active instanceof HTMLElement) || active === this.element) return;
419
627
  if (!this.element.contains(active)) return;
420
628
  this.#rootTabindex.lend(this.element);
421
- this.element.focus();
422
629
  this.#rescuedFocus = true;
630
+ this.element.focus();
423
631
  }
424
- /**
425
- * Restores authored roles and visibility after leaving readonly mode.
426
- *
427
- * @returns whether focus is standing on the root because {@link #rescueFocus}
428
- * put it there, and therefore belongs back on the Tab stop.
429
- */
430
632
  #releaseReadonly() {
431
633
  const returning = this.#rescuedFocus && document.activeElement === this.element;
432
634
  this.#rescuedFocus = false;
433
635
  this.#rootRole.return(this.element);
636
+ for (const symbol of this.symbolTargets) {
637
+ this.#symbolRole.return(symbol);
638
+ this.#symbolAriaHidden.return(symbol);
639
+ }
434
640
  this.#symbolRole.returnAll();
435
641
  this.#symbolAriaHidden.returnAll();
436
642
  this.#rootTabindex.returnAll();
437
643
  return returning;
438
644
  }
439
- /** Normalizes a raw value to an integer ordinal in the live DOM range. */
440
645
  #normalize(raw) {
441
646
  const maximum = this.symbolTargets.length;
442
- const ordinal = Number.isFinite(raw) ? Math.round(raw) : this.#minValue;
647
+ const ordinal = Math.round(raw);
443
648
  return Math.min(maximum, Math.max(this.#minValue, ordinal));
444
649
  }
445
- /** Lowest selectable value: 0 when clearable, otherwise 1. */
446
650
  get #minValue() {
447
651
  return this.clearableValue ? 0 : 1;
448
652
  }
449
- /** Returns a target's 1-based position, or null when the action host is invalid. */
450
653
  #symbolOrdinal(target) {
451
654
  const targets = this.symbolTargets;
452
655
  const index = targets.indexOf(target);
453
656
  return index < 0 ? null : index + 1;
454
657
  }
658
+ get #safeValue() {
659
+ return this.#numbers.read(
660
+ this,
661
+ "value",
662
+ this.valueValue,
663
+ _RatingController.values.value.default,
664
+ _RatingController.valueConstraints.value
665
+ );
666
+ }
455
667
  };
456
668
 
457
669
  export { RatingController };
458
- //# sourceMappingURL=rating_controller.js.map
459
670
  //# sourceMappingURL=rating_controller.js.map