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,13 +1,31 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/data_grid_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 logicalArrowKey(key, element) {
12
30
  if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
13
31
  if (!isRtl(element)) return key;
@@ -18,7 +36,6 @@ function isReservedArrowChord(event, allow = []) {
18
36
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
37
  }
20
38
 
21
- // src/utils/field_mirror.ts
22
39
  function writeFields(container, values, { name, form = "" }) {
23
40
  const current = [...container.children];
24
41
  const submits = current.length === values.length && current.every(
@@ -41,7 +58,6 @@ function commitField(target) {
41
58
  target.dispatchEvent(new Event("change", { bubbles: true }));
42
59
  }
43
60
 
44
- // src/utils/interactive_host.ts
45
61
  var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
46
62
  function isInteractiveHost(element) {
47
63
  if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
@@ -58,27 +74,33 @@ function isInteractiveHost(element) {
58
74
  return false;
59
75
  }
60
76
 
61
- // src/utils/microtask_coalescer.ts
77
+ var ListenerSet = class {
78
+ #abort = new AbortController();
79
+ add(target, type, handler, options) {
80
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
81
+ }
82
+ dispose() {
83
+ this.#abort.abort();
84
+ this.#abort = new AbortController();
85
+ }
86
+ };
87
+
62
88
  var MicrotaskCoalescer = class {
63
89
  #run;
64
90
  #queued = false;
65
91
  #active = false;
66
92
  #generation = 0;
67
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
68
93
  constructor(run) {
69
94
  this.#run = run;
70
95
  }
71
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
72
96
  activate() {
73
97
  this.#active = true;
74
98
  }
75
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
76
99
  cancel() {
77
100
  this.#active = false;
78
101
  this.#queued = false;
79
102
  this.#generation += 1;
80
103
  }
81
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
82
104
  schedule() {
83
105
  if (!this.#active || this.#queued) return;
84
106
  this.#queued = true;
@@ -91,7 +113,46 @@ var MicrotaskCoalescer = class {
91
113
  }
92
114
  };
93
115
 
94
- // src/controllers/data_grid_controller.ts
116
+ var MorphRenderWatcher = class {
117
+ #listeners = new ListenerSet();
118
+ #pass;
119
+ #element = null;
120
+ #onMorph = () => this.#pass.schedule();
121
+ constructor(run) {
122
+ this.#pass = new MicrotaskCoalescer(run);
123
+ }
124
+ observe(element) {
125
+ if (this.#element === element) return;
126
+ this.disconnect();
127
+ this.#element = element;
128
+ this.#pass.activate();
129
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
130
+ }
131
+ schedule() {
132
+ this.#pass.schedule();
133
+ }
134
+ disconnect() {
135
+ this.#listeners.dispose();
136
+ this.#pass.cancel();
137
+ this.#element = null;
138
+ }
139
+ };
140
+
141
+ var MoveCounter = class {
142
+ #sequence = 0;
143
+ record() {
144
+ this.#sequence += 1;
145
+ return this.#sequence;
146
+ }
147
+ isLatest(token) {
148
+ return token === this.#sequence;
149
+ }
150
+ };
151
+
152
+ function targetSelector(identifier, name) {
153
+ return `[data-${identifier}-target~="${name}"]`;
154
+ }
155
+
95
156
  var SORT_CYCLE = ["none", "ascending", "descending"];
96
157
  var OBSERVED_ATTRIBUTES = ["aria-selected", "data-value"];
97
158
  function setAttributeIfChanged(element, name, value) {
@@ -110,6 +171,8 @@ function nextSortDirection(current) {
110
171
  return SORT_CYCLE[(from + 1) % SORT_CYCLE.length] ?? "ascending";
111
172
  }
112
173
  var DataGridController = class extends Controller {
174
+ #moves = new MoveCounter();
175
+ #stopHolder = null;
113
176
  static targets = ["columnHeader", "row", "cell", "fields"];
114
177
  static values = {
115
178
  selection: { type: String, default: "none" },
@@ -118,57 +181,25 @@ var DataGridController = class extends Controller {
118
181
  };
119
182
  static actions = ["onKeydown", "sort", "toggleSelect"];
120
183
  static events = ["selectionchange", "sort", "reconcile"];
121
- /**
122
- * Collapses the target callbacks, Value changes and observed row writes of one
123
- * DOM mutation into a single pass, and refuses to run before `connect()` or
124
- * after `disconnect()`.
125
- *
126
- * Stimulus reports every target one at a time, so an ungated pass would re-walk
127
- * the whole grid once per authored cell on mount and once per streamed cell
128
- * afterwards — quadratic in the cell count both times.
129
- */
130
- #reconcile = new MicrotaskCoalescer(() => this.#reconcileRows());
131
- /** Watches the row attributes a page can rewrite in place; set while connected. */
184
+ #reconcile = new MorphRenderWatcher(() => this.#reconcileRows());
132
185
  #observer = null;
133
- /** The selection last settled: on connect, by the user, or by a reported pass. */
134
186
  #settled = /* @__PURE__ */ new Map();
135
- /**
136
- * Establishes a single tab stop across all navigable cells/headers, brings the
137
- * rows to their baseline, and settles the selection without reporting it.
138
- *
139
- * The whole baseline runs here rather than in the Value callbacks: Stimulus
140
- * delivers those before `connect()`, where the pass is refused, and a
141
- * re-attached element reuses its cached context, whose value observer does not
142
- * fire again.
143
- */
144
187
  connect() {
145
188
  this.#restoreBaseline();
146
189
  this.#settled = this.#selection();
147
- this.#reconcile.activate();
190
+ this.#reconcile.observe(this.element);
148
191
  this.#observeRows();
149
192
  }
150
- /** Releases the row observer and drops a queued pass, so neither outlives the element. */
151
193
  disconnect() {
152
- this.#reconcile.cancel();
194
+ this.#reconcile.disconnect();
153
195
  this.#observer?.disconnect();
154
196
  this.#observer = null;
155
197
  }
156
- /**
157
- * Rebuilds the DOM-owned baselines from the live grid: exactly one navigable
158
- * cell is in the Tab sequence, `aria-multiselectable` follows `selection`, every
159
- * selectable row carries an explicit `aria-selected`, and the fields mirror the
160
- * selected rows.
161
- *
162
- * The tab stop keeps whichever cell already holds it, so a rebuild triggered by
163
- * an unrelated row arriving does not throw the user's position away; only when
164
- * no cell holds it — the grid is fresh, or the holder was removed — does the
165
- * first navigable cell take over. Without that fallback a grid whose active row
166
- * is removed keeps every cell at `-1` and drops out of the Tab sequence
167
- * entirely.
168
- */
169
198
  #restoreBaseline() {
170
199
  const cells = this.#navigableCells();
171
- const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
200
+ const holder = this.#stopHolder;
201
+ const kept = holder !== null && holder.tabIndex === 0 && cells.includes(holder);
202
+ const active = (kept ? holder : cells.find((cell) => cell.tabIndex === 0)) ?? cells[0];
172
203
  if (active) this.#setActiveCell(active, { focus: false }, cells);
173
204
  this.#syncSelectable();
174
205
  const pageWrote = this.#takePageRecords();
@@ -176,83 +207,49 @@ var DataGridController = class extends Controller {
176
207
  this.#dropOwnRecords(pageWrote);
177
208
  this.#mirrorFields(false);
178
209
  }
179
- /**
180
- * The pass the page's changes run: the baselines are rebuilt, then a selection
181
- * that moved is reported. The report comes last, so rows a subscriber rewrites
182
- * are the next pass's to settle.
183
- */
184
210
  #reconcileRows() {
185
211
  this.#restoreBaseline();
186
212
  this.#reportMove();
187
213
  }
188
- /**
189
- * Reports the selection as `reconcile` when it differs from the one last
190
- * settled. The settled selection is replaced before dispatching, so a move a
191
- * subscriber makes is measured against what it was told.
192
- */
193
214
  #reportMove() {
194
215
  const selection = this.#selection();
195
216
  if (sameSelection(selection, this.#settled)) return;
196
217
  this.#settled = selection;
197
218
  this.dispatch("reconcile", { detail: { rows: [...selection.keys()] } });
198
219
  }
199
- /**
200
- * Re-renders `aria-multiselectable`, the rows and the fields when application
201
- * code (or a Turbo morph) changes `selection` at runtime; a selection the new
202
- * mode collapses is reported as `reconcile`.
203
- */
204
220
  selectionValueChanged() {
205
221
  this.#reconcile.schedule();
206
222
  }
207
- /** Rebuilds the submitted fields when the public name changes at runtime. */
208
223
  nameValueChanged() {
209
224
  this.#reconcile.schedule();
210
225
  }
211
- /** Repoints the submitted fields when the owning form changes at runtime. */
212
226
  formValueChanged() {
213
227
  this.#reconcile.schedule();
214
228
  }
215
- /** Seeds a fields container inserted after connect from the current selection. */
216
229
  fieldsTargetConnected() {
217
230
  this.#reconcile.schedule();
218
231
  }
219
- /** Re-establishes the baselines for a row added after connect. */
232
+ fieldsTargetDisconnected() {
233
+ this.#reconcile.schedule();
234
+ }
220
235
  rowTargetConnected() {
221
236
  this.#reconcile.schedule();
222
237
  }
223
- /** Settles the selection again when a row leaves the grid or drops its target token. */
224
238
  rowTargetDisconnected() {
225
239
  this.#reconcile.schedule();
226
240
  }
227
- /** Re-establishes the tab stop when a cell joins the grid after connect. */
228
241
  cellTargetConnected() {
229
242
  this.#reconcile.schedule();
230
243
  }
231
- /** Re-establishes the tab stop when a cell leaves the grid. */
232
244
  cellTargetDisconnected() {
233
245
  this.#reconcile.schedule();
234
246
  }
235
- /** Re-establishes the tab stop when a header joins the grid after connect. */
236
247
  columnHeaderTargetConnected() {
237
248
  this.#reconcile.schedule();
238
249
  }
239
- /** Re-establishes the tab stop when a header leaves the grid. */
240
250
  columnHeaderTargetDisconnected() {
241
251
  this.#reconcile.schedule();
242
252
  }
243
- /**
244
- * Brings the authored rows to the shape the APG requires, without changing
245
- * which rows the author chose.
246
- *
247
- * Every selectable row gets an explicit value — an absent `aria-selected` means
248
- * "not selectable" in ARIA, so a forgotten attribute hides a selectable row —
249
- * and a single-select grid keeps at most one `true`, first in DOM order.
250
- * A grid that declares `selection="none"` has no selectable rows, so the
251
- * attribute is removed rather than written: in ARIA its absence is what "not
252
- * selectable" looks like.
253
- *
254
- * @stimeoRenderRoot
255
- */
256
253
  #normalizeSelection() {
257
254
  const rows = this.rowTargets;
258
255
  if (this.selectionValue === "none") {
@@ -269,13 +266,6 @@ var DataGridController = class extends Controller {
269
266
  }
270
267
  }
271
268
  }
272
- /**
273
- * Mirrors `selection="multiple"` onto `aria-multiselectable` (APG Grid) so SRs
274
- * announce that more than one row can be selected; cleared for single/none so a
275
- * grid never carries a misleading attribute.
276
- *
277
- * @stimeoRenderRoot
278
- */
279
269
  #syncSelectable() {
280
270
  if (this.selectionValue === "multiple") {
281
271
  setAttributeIfChanged(this.element, "aria-multiselectable", "true");
@@ -283,30 +273,32 @@ var DataGridController = class extends Controller {
283
273
  this.element.removeAttribute("aria-multiselectable");
284
274
  }
285
275
  }
286
- /** Cycles the activated column header's sort and emits `sort`. */
287
- sort(event) {
288
- const header = event.currentTarget;
289
- if (!this.columnHeaderTargets.includes(header)) return;
290
- if (event.defaultPrevented) return;
291
- const control = this.#claimingControl(event, header);
276
+ sort(source) {
277
+ const { event, host, origin, reason } = actionSource(source);
278
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
279
+ return;
280
+ const header = host?.closest(targetSelector(this.identifier, "columnHeader"));
281
+ if (!header || !this.columnHeaderTargets.includes(header)) return;
282
+ if (event?.defaultPrevented) return;
283
+ const control = event ? this.#claimingControl(event, header) : null;
292
284
  if (control && !(control instanceof HTMLButtonElement)) return;
293
285
  const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
294
286
  for (const other of this.columnHeaderTargets) {
295
287
  other.setAttribute("aria-sort", other === header ? direction : "none");
296
288
  }
297
289
  this.#setActiveCell(header, { focus: false });
298
- this.dispatch("sort", { detail: { column: header, direction } });
290
+ this.dispatch("sort", { detail: { column: header, direction, reason } });
299
291
  }
300
- /** Toggles selection of the row owning the event target. Bound optionally. */
301
- toggleSelect(event) {
292
+ toggleSelect(source) {
293
+ const { event, host, origin, reason } = actionSource(source);
294
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
295
+ return;
302
296
  if (this.selectionValue === "none") return;
303
- if (event.defaultPrevented) return;
304
- const host = event.currentTarget;
305
- if (this.#claimedByDescendant(event, host)) return;
306
- const row = host.closest("[role='row']");
307
- if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
297
+ if (event?.defaultPrevented) return;
298
+ if (event && host instanceof HTMLElement && this.#claimedByDescendant(event, host)) return;
299
+ const row = host?.closest(targetSelector(this.identifier, "row"));
300
+ if (row && this.rowTargets.includes(row)) this.#toggleRow(row, reason);
308
301
  }
309
- /** Grid navigation + sort/select activation. Bound to cells and headers. */
310
302
  onKeydown(event) {
311
303
  if (event.defaultPrevented) return;
312
304
  if (isReservedArrowChord(event)) return;
@@ -351,38 +343,15 @@ var DataGridController = class extends Controller {
351
343
  this.#setActiveCell(target, { focus: true }, matrix.flat());
352
344
  }
353
345
  }
354
- /**
355
- * Whether the event was addressed to a control inside `host` rather than to the
356
- * grid.
357
- *
358
- * Cells and headers hold consumer markup, and APG's grid pattern expects that
359
- * markup to include working controls — a row action button, an inline editor.
360
- * Those own their own keystrokes and clicks, so the grid stands down entirely
361
- * rather than acting in parallel. An editable host (its `contenteditable` state
362
- * is inherited, so the walk is explicit) counts the same way.
363
- */
364
346
  #claimedByDescendant(event, host) {
365
347
  return this.#claimingControl(event, host) !== null;
366
348
  }
367
- /**
368
- * The nested control this event belongs to, or `null` when the host owns it.
369
- *
370
- * Naming the control, rather than answering yes or no, is what lets the click
371
- * path treat a sortable header's `<button>` as the activation it is while every
372
- * other control still takes the event away.
373
- */
374
349
  #claimingControl(event, host) {
375
350
  const source = event.target;
376
351
  const control = source.closest(INTERACTIVE_HOST_SELECTOR);
377
352
  if (control && host.contains(control)) return control;
378
353
  return isInteractiveHost(source) ? source : null;
379
354
  }
380
- /**
381
- * Performs a header's sort or a cell row's selection toggle on activation.
382
- *
383
- * @stimeoRuntimeOnly `selection` decides whether this one activation toggles the row; the sort it
384
- * cycles does not depend on it.
385
- */
386
355
  #activate(cell) {
387
356
  if (this.columnHeaderTargets.includes(cell)) {
388
357
  this.#cycleSort(cell);
@@ -392,20 +361,14 @@ var DataGridController = class extends Controller {
392
361
  const row = cell.closest("[role='row']");
393
362
  if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
394
363
  }
395
- /** Cycles a header's sort on keyboard activation and emits `sort`. */
396
364
  #cycleSort(header) {
397
365
  const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
398
366
  for (const other of this.columnHeaderTargets) {
399
367
  other.setAttribute("aria-sort", other === header ? direction : "none");
400
368
  }
401
- this.dispatch("sort", { detail: { column: header, direction } });
402
- }
403
- /**
404
- * Toggles a row's `aria-selected`, honoring single vs. multiple selection.
405
- *
406
- * @stimeoRuntimeOnly `selection` decides how this one toggle treats the other rows.
407
- */
408
- #toggleRow(row) {
369
+ this.dispatch("sort", { detail: { column: header, direction, reason: "user" } });
370
+ }
371
+ #toggleRow(row, reason = "user") {
409
372
  const selected = row.getAttribute("aria-selected") === "true";
410
373
  const pageWrote = this.#takePageRecords();
411
374
  if (this.selectionValue === "single") {
@@ -415,22 +378,23 @@ var DataGridController = class extends Controller {
415
378
  }
416
379
  row.setAttribute("aria-selected", selected ? "false" : "true");
417
380
  this.#dropOwnRecords(pageWrote);
418
- this.#settled = this.#selection();
381
+ const selection = this.#selection();
382
+ const changed = !sameSelection(selection, this.#settled);
383
+ this.#settled = selection;
384
+ if (!changed) {
385
+ this.#mirrorFields(false);
386
+ return;
387
+ }
388
+ const token = this.#moves.record();
419
389
  this.#mirrorFields(true);
420
- this.dispatch("selectionchange", { detail: { rows: [...this.#settled.keys()] } });
390
+ if (!this.#moves.isLatest(token)) return;
391
+ this.dispatch("selectionchange", { detail: { rows: [...selection.keys()], reason } });
421
392
  }
422
- /** The selected rows in DOM order, each with the `data-value` it submits, if any. */
423
393
  #selection() {
424
394
  return new Map(
425
395
  this.rowTargets.filter((row) => row.getAttribute("aria-selected") === "true").map((row) => [row, row.dataset.value])
426
396
  );
427
397
  }
428
- /**
429
- * Watches the rows' `aria-selected` and `data-value`. Every write the grid makes
430
- * to them sits between a take and a drop of the observer's queue, so each record
431
- * the callback receives is the page's, and only a record on one of the grid's own
432
- * rows schedules a pass.
433
- */
434
398
  #observeRows() {
435
399
  const observer = new MutationObserver((records) => {
436
400
  if (this.#concernsRows(records)) this.#reconcile.schedule();
@@ -442,40 +406,18 @@ var DataGridController = class extends Controller {
442
406
  });
443
407
  this.#observer = observer;
444
408
  }
445
- /**
446
- * Empties the observer's queue before the grid writes to the rows, and says
447
- * whether the page had queued anything about them. Those records still owe a
448
- * pass: a toggle in `multiple` mode rewrites only its own row, so it does not fold
449
- * in what the page wrote to the others.
450
- */
451
409
  #takePageRecords() {
452
410
  const records = this.#observer?.takeRecords() ?? [];
453
411
  return records.length > 0 && this.#concernsRows(records);
454
412
  }
455
- /**
456
- * Whether any record is about one of this grid's own rows. A widget nested in a
457
- * cell writes the same attributes on its own elements, and those are no reason
458
- * to reconcile the grid.
459
- */
460
413
  #concernsRows(records) {
461
414
  const rows = new Set(this.rowTargets);
462
415
  return records.some((record) => rows.has(record.target));
463
416
  }
464
- /**
465
- * Drops the records the grid's own writes just queued, so the observer never
466
- * takes them for the page's, then schedules the pass the page's records owe.
467
- */
468
417
  #dropOwnRecords(pageWrote) {
469
418
  this.#observer?.takeRecords();
470
419
  if (pageWrote) this.#reconcile.schedule();
471
420
  }
472
- /**
473
- * Mirrors the selected rows' `data-value` into the optional fields container.
474
- * A selected row without one submits nothing, so a grid whose rows carry no
475
- * value submits an empty set rather than a row of blanks.
476
- *
477
- * @stimeoRenderRoot
478
- */
479
421
  #mirrorFields(notify) {
480
422
  if (!this.hasFieldsTarget) return;
481
423
  const values = this.rowTargets.filter((row) => row.getAttribute("aria-selected") === "true").map((row) => row.dataset.value).filter((value) => value !== void 0);
@@ -484,17 +426,8 @@ var DataGridController = class extends Controller {
484
426
  commitField(this.fieldsTarget);
485
427
  }
486
428
  }
487
- /**
488
- * Makes `cell` the single tabbable cell (roving) and optionally focuses it.
489
- *
490
- * `cells` lets a caller that already walked the grid hand its collection over,
491
- * so one keystroke rebuilds the matrix once instead of twice. The write is
492
- * skipped where the attribute already holds the wanted value — comparing the
493
- * attribute rather than the IDL property, because a cell with no `tabindex` at
494
- * all reports `-1` and would then never receive the attribute it needs to be
495
- * focusable.
496
- */
497
429
  #setActiveCell(cell, { focus }, cells) {
430
+ this.#stopHolder = cell;
498
431
  for (const candidate of cells ?? this.#navigableCells()) {
499
432
  const wanted = candidate === cell ? "0" : "-1";
500
433
  if (candidate.getAttribute("tabindex") !== wanted) {
@@ -503,11 +436,9 @@ var DataGridController = class extends Controller {
503
436
  }
504
437
  if (focus) cell.focus();
505
438
  }
506
- /** All navigable elements (headers + cells) in DOM order. */
507
439
  #navigableCells() {
508
440
  return this.#matrix().flat();
509
441
  }
510
- /** The grid as rows of navigable cells, derived from each `role="row"`. */
511
442
  #matrix() {
512
443
  const navigable = /* @__PURE__ */ new Set([...this.columnHeaderTargets, ...this.cellTargets]);
513
444
  const rows = Array.from(this.element.querySelectorAll("[role='row']"));
@@ -517,7 +448,6 @@ var DataGridController = class extends Controller {
517
448
  )
518
449
  ).filter((cells) => cells.length > 0);
519
450
  }
520
- /** Finds `[rowIndex, colIndex]` of `cell` within `matrix`, or null. */
521
451
  #locate(matrix, cell) {
522
452
  for (let row = 0; row < matrix.length; row++) {
523
453
  const col = (matrix[row] ?? []).indexOf(cell);
@@ -525,7 +455,6 @@ var DataGridController = class extends Controller {
525
455
  }
526
456
  return null;
527
457
  }
528
- /** The cell at `[row, col]`, clamping `col` to that row's last cell. */
529
458
  #cellInRow(matrix, row, col) {
530
459
  const cells = matrix[row];
531
460
  if (!cells || cells.length === 0) return void 0;
@@ -534,5 +463,4 @@ var DataGridController = class extends Controller {
534
463
  };
535
464
 
536
465
  export { DataGridController };
537
- //# sourceMappingURL=data_grid_controller.js.map
538
466
  //# sourceMappingURL=data_grid_controller.js.map