stimeo-ui 0.8.0 → 0.9.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.
@@ -58,8 +58,62 @@ var MicrotaskCoalescer = class {
58
58
  }
59
59
  };
60
60
 
61
+ // src/utils/owned_pointer_session.ts
62
+ var OwnedPointerSession = class {
63
+ pointerId;
64
+ #owner;
65
+ #handlers;
66
+ #abort = new AbortController();
67
+ #active = true;
68
+ constructor(start, owner, handlers) {
69
+ this.pointerId = start.pointerId;
70
+ this.#owner = owner;
71
+ this.#handlers = handlers;
72
+ const { signal } = this.#abort;
73
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
74
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
75
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
76
+ owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
77
+ try {
78
+ owner.setPointerCapture?.(this.pointerId);
79
+ } catch {
80
+ }
81
+ }
82
+ /** Whether this session still owns its pointer and listeners. */
83
+ get active() {
84
+ return this.#active;
85
+ }
86
+ /** Whether `event` belongs to the initiating pointer of the live session. */
87
+ owns(event) {
88
+ return this.#active && event.pointerId === this.pointerId;
89
+ }
90
+ /** Releases capture/listeners and invokes the end callback exactly once. */
91
+ end() {
92
+ if (!this.#active) return;
93
+ this.#active = false;
94
+ this.#abort.abort();
95
+ try {
96
+ this.#owner.releasePointerCapture?.(this.pointerId);
97
+ } catch {
98
+ }
99
+ this.#handlers.end?.();
100
+ }
101
+ #onMove = (event) => {
102
+ if (this.owns(event)) this.#handlers.move(event);
103
+ };
104
+ #onEndEvent = (event) => {
105
+ if (this.owns(event)) this.end();
106
+ };
107
+ #onLostCapture = (event) => {
108
+ const pointerId = event.pointerId;
109
+ if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
110
+ this.end();
111
+ };
112
+ };
113
+
61
114
  // src/controllers/color_picker_controller.ts
62
115
  var COLOR_PROPERTY = "--stimeo--color";
116
+ var VALUE_TEXT_ATTRIBUTE = "data-value-text";
63
117
  var CHANNEL_RANGE = {
64
118
  hue: [0, 360],
65
119
  saturation: [0, 100],
@@ -79,10 +133,10 @@ var ColorPickerController = class extends Controller {
79
133
  get #mirrored() {
80
134
  return this.logicalTrackValue && isRtl(this.element);
81
135
  }
82
- /** The current color in the editing model. */
136
+ /** The current color in the editing model; its alpha is 100 while `alpha` is off. */
83
137
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
84
- /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
85
- #dragAbort = null;
138
+ /** The pointer that owns the live drag, with the slider whose geometry maps it. */
139
+ #drag = null;
86
140
  /** Color the last repaint settled on, so a configuration-driven move is reported once. */
87
141
  #committedHex = null;
88
142
  /**
@@ -93,25 +147,48 @@ var ColorPickerController = class extends Controller {
93
147
  /** Seeds the model from the initial hex value and renders every surface. */
94
148
  connect() {
95
149
  this.#repaint.activate();
96
- const parsed = hexToHsla(this.valueValue);
97
- if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
150
+ this.#adoptValue();
98
151
  this.#render();
99
152
  }
100
153
  /** Cancels any active pointer drag so document listeners never leak. */
101
154
  disconnect() {
102
155
  this.#repaint.cancel();
103
- this.#dragAbort?.abort();
104
- this.#dragAbort = null;
156
+ this.#endDrag();
105
157
  }
106
158
  /** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
107
159
  alphaValueChanged() {
108
160
  this.#repaint.schedule();
109
161
  }
162
+ /** Adopts a color application code (or a Turbo morph) put in `value` at runtime. */
163
+ valueValueChanged() {
164
+ if (this.valueValue === this.#committedHex) return;
165
+ this.#repaint.schedule();
166
+ }
167
+ /** Hydrates a channel slider inserted or replaced at runtime. */
168
+ sliderTargetConnected(slider) {
169
+ this.#renderSlider(slider);
170
+ }
171
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
172
+ sliderTargetDisconnected(slider) {
173
+ if (this.#drag?.slider === slider) this.#endDrag();
174
+ }
175
+ /** Fills a hex input inserted or replaced at runtime with the current color. */
176
+ hexTargetConnected(hex) {
177
+ this.#mirrorColor(hex, this.#hexString());
178
+ }
179
+ /** Fills a form field inserted or replaced at runtime with the current color. */
180
+ fieldTargetConnected(field) {
181
+ this.#mirrorColor(field, this.#hexString());
182
+ }
183
+ /** Publishes the current color on a preview inserted or replaced at runtime. */
184
+ previewTargetConnected(preview) {
185
+ this.#publishColor(preview, this.#hexString());
186
+ }
110
187
  /** Keyboard stepping on the focused channel slider (APG Slider model). */
111
188
  onKeydown(event) {
112
189
  if (isReservedArrowChord(event)) return;
113
190
  const slider = event.currentTarget;
114
- const channel = this.#channelOf(slider);
191
+ const channel = this.#editableChannel(slider);
115
192
  if (!channel) return;
116
193
  const [min, max] = this.#rangeOf(slider, channel);
117
194
  const value = this.#color[channel];
@@ -143,34 +220,36 @@ var ColorPickerController = class extends Controller {
143
220
  event.preventDefault();
144
221
  this.#setChannel(channel, next, min, max);
145
222
  }
146
- /** Begins a pointer drag on a channel slider and tracks movement. */
223
+ /** Begins a primary-button drag on a channel slider, owned by its own pointer. */
147
224
  onPointerDown(event) {
225
+ if (event.button !== 0 || this.#drag) return;
148
226
  const slider = event.currentTarget;
149
- const channel = this.#channelOf(slider);
227
+ const channel = this.#editableChannel(slider);
150
228
  if (!channel) return;
151
- event.preventDefault();
152
- slider.focus();
153
229
  const [min, max] = this.#rangeOf(slider, channel);
154
230
  const mirrored = this.#mirrored;
155
231
  const update = (clientX) => {
156
232
  const rect = slider.getBoundingClientRect();
157
- if (rect.width === 0) return;
233
+ if (rect.width === 0) return false;
158
234
  const offset = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
159
235
  const fraction = mirrored ? 1 - offset : offset;
160
236
  this.#setChannel(channel, min + fraction * (max - min), min, max);
237
+ return true;
161
238
  };
162
- update(event.clientX);
163
- this.#dragAbort?.abort();
164
- const abort = new AbortController();
165
- this.#dragAbort = abort;
166
- const onMove = (move) => update(move.clientX);
167
- const onUp = () => {
168
- abort.abort();
169
- this.#dragAbort = null;
170
- };
171
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
172
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
173
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
239
+ if (!update(event.clientX)) return;
240
+ event.preventDefault();
241
+ slider.focus();
242
+ const drag = { pointer: null, slider };
243
+ drag.pointer = new OwnedPointerSession(event, slider, {
244
+ move: (move) => {
245
+ if (slider.isConnected) update(move.clientX);
246
+ else this.#endDrag();
247
+ },
248
+ end: () => {
249
+ if (this.#drag === drag) this.#drag = null;
250
+ }
251
+ });
252
+ this.#drag = drag;
174
253
  }
175
254
  /** Parses the hex input on confirm and syncs every channel + surface. */
176
255
  onHexInput() {
@@ -180,9 +259,22 @@ var ColorPickerController = class extends Controller {
180
259
  this.hexTarget.value = this.#hexString();
181
260
  return;
182
261
  }
183
- this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
262
+ this.#color = this.#opaqueUnlessEnabled(parsed);
184
263
  this.#commitColor();
185
264
  }
265
+ /** Replaces the model with the color `value` names, leaving an unparsable one alone. */
266
+ #adoptValue() {
267
+ const parsed = hexToHsla(this.valueValue);
268
+ if (parsed) this.#color = this.#opaqueUnlessEnabled(parsed);
269
+ }
270
+ /**
271
+ * The model a parsed color implies: alpha only survives while its channel is
272
+ * enabled, because `hexString()` would otherwise emit `#RRGGBB` while `change`
273
+ * reported `rgba.a < 1`.
274
+ */
275
+ #opaqueUnlessEnabled(parsed) {
276
+ return this.alphaValue ? parsed : { ...parsed, alpha: 100 };
277
+ }
186
278
  /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
187
279
  #setChannel(channel, raw, min, max) {
188
280
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
@@ -201,32 +293,57 @@ var ColorPickerController = class extends Controller {
201
293
  }
202
294
  }
203
295
  /**
204
- * Reflects the model onto sliders, the hex input, preview, and form field.
296
+ * Reflects the model onto sliders, the hex input, preview, form field, and the
297
+ * `value` Value it serializes into.
205
298
  *
206
299
  * @stimeoRenderRoot
207
300
  */
208
301
  #render() {
209
- for (const slider of this.sliderTargets) {
210
- const channel = this.#channelOf(slider);
211
- if (!channel) continue;
212
- const value = this.#color[channel];
213
- slider.setAttribute("aria-valuenow", String(value));
214
- slider.setAttribute("aria-valuetext", valueText(channel, value));
215
- }
302
+ for (const slider of this.sliderTargets) this.#renderSlider(slider);
216
303
  const hex = this.#hexString();
217
304
  this.#committedHex = hex;
218
- if (this.hasHexTarget) this.hexTarget.value = hex;
219
- for (const field of this.fieldTargets) field.value = hex;
220
- for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
221
- this.element.style.setProperty(COLOR_PROPERTY, hex);
305
+ if (this.valueValue !== hex) this.valueValue = hex;
306
+ if (this.hasHexTarget) this.#mirrorColor(this.hexTarget, hex);
307
+ for (const field of this.fieldTargets) this.#mirrorColor(field, hex);
308
+ for (const preview of this.previewTargets) this.#publishColor(preview, hex);
309
+ this.#publishColor(this.element, hex);
310
+ }
311
+ /** Writes one slider's announced range, value, and value text, skipping equal ones. */
312
+ #renderSlider(slider) {
313
+ const channel = this.#channelOf(slider);
314
+ if (!channel) return;
315
+ const [min, max] = this.#rangeOf(slider, channel);
316
+ const value = this.#color[channel];
317
+ const attributes = {
318
+ "aria-valuemin": String(min),
319
+ "aria-valuemax": String(max),
320
+ "aria-valuenow": String(value),
321
+ "aria-valuetext": this.#valueText(slider, channel, value)
322
+ };
323
+ for (const [name, next] of Object.entries(attributes)) {
324
+ if (slider.getAttribute(name) !== next) slider.setAttribute(name, next);
325
+ }
326
+ }
327
+ /** Mirrors the color into an input, leaving an already-equal value untouched. */
328
+ #mirrorColor(input, hex) {
329
+ if (input.value !== hex) input.value = hex;
330
+ }
331
+ /** Publishes the color as the consumer's CSS hook, skipping an equal value. */
332
+ #publishColor(element, hex) {
333
+ if (element.style.getPropertyValue(COLOR_PROPERTY) !== hex) {
334
+ element.style.setProperty(COLOR_PROPERTY, hex);
335
+ }
222
336
  }
223
337
  /**
224
- * Repaints after `alpha` changed at runtime and reports a color this controller
225
- * settled on. Disabling alpha drops it from the model, so the committed color can
226
- * move without a user edit; `change` stays reserved for the picker's own actions.
338
+ * Repaints after a declarative input changed at runtime and reports a color this
339
+ * controller settled on. Disabling alpha drops it from the model and an outside
340
+ * `value` names another color, so the committed color can move without a user
341
+ * edit; `change` stays reserved for the picker's own actions.
227
342
  */
228
343
  #reconcileColor() {
229
344
  const previous = this.#committedHex;
345
+ if (previous !== null && this.valueValue !== previous) this.#adoptValue();
346
+ if (!this.alphaValue) this.#color.alpha = 100;
230
347
  this.#render();
231
348
  if (previous !== null && this.#committedHex !== previous) {
232
349
  this.dispatch("reconcile", { detail: this.#settledDetail() });
@@ -247,7 +364,27 @@ var ColorPickerController = class extends Controller {
247
364
  /** Reads a slider's `data-channel`, if it is a known channel. */
248
365
  #channelOf(slider) {
249
366
  const channel = slider.getAttribute("data-channel");
250
- return channel && channel in CHANNEL_RANGE ? channel : null;
367
+ return channel && Object.hasOwn(CHANNEL_RANGE, channel) ? channel : null;
368
+ }
369
+ /**
370
+ * The channel a slider edits, or null when this picker edits none through it. An
371
+ * alpha slider authored while `alpha` is off edits nothing: moving it would leave
372
+ * the model translucent behind an opaque `#RRGGBB`.
373
+ */
374
+ #editableChannel(slider) {
375
+ const channel = this.#channelOf(slider);
376
+ return channel === "alpha" && !this.alphaValue ? null : channel;
377
+ }
378
+ /** The channel's announced text: the slider's template, or the built-in English. */
379
+ #valueText(slider, channel, value) {
380
+ const template = slider.getAttribute(VALUE_TEXT_ATTRIBUTE);
381
+ return template ? template.replaceAll("{value}", String(value)) : defaultValueText(channel, value);
382
+ }
383
+ /** Ends the live drag so no further movement of that pointer reaches the model. */
384
+ #endDrag() {
385
+ const drag = this.#drag;
386
+ this.#drag = null;
387
+ drag?.pointer?.end();
251
388
  }
252
389
  /**
253
390
  * A slider's `[min, max]` from aria-valuemin/max, falling back per channel.
@@ -263,7 +400,7 @@ var ColorPickerController = class extends Controller {
263
400
  ];
264
401
  }
265
402
  };
266
- function valueText(channel, value) {
403
+ function defaultValueText(channel, value) {
267
404
  const label = channel.charAt(0).toUpperCase() + channel.slice(1);
268
405
  const unit = channel === "hue" ? "degrees" : "percent";
269
406
  return `${label} ${value} ${unit}`;
@@ -18,6 +18,56 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
20
 
21
+ // src/utils/interactive_host.ts
22
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
23
+ function isInteractiveHost(element) {
24
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
25
+ let current = element;
26
+ while (current) {
27
+ const raw = current.getAttribute("contenteditable");
28
+ if (raw !== null) {
29
+ const value = raw.trim().toLowerCase();
30
+ if (value === "false") return false;
31
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
32
+ }
33
+ current = current.parentElement;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ // src/utils/microtask_coalescer.ts
39
+ var MicrotaskCoalescer = class {
40
+ #run;
41
+ #queued = false;
42
+ #active = false;
43
+ #generation = 0;
44
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
45
+ constructor(run) {
46
+ this.#run = run;
47
+ }
48
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
49
+ activate() {
50
+ this.#active = true;
51
+ }
52
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
53
+ cancel() {
54
+ this.#active = false;
55
+ this.#queued = false;
56
+ this.#generation += 1;
57
+ }
58
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
59
+ schedule() {
60
+ if (!this.#active || this.#queued) return;
61
+ this.#queued = true;
62
+ const generation = this.#generation;
63
+ queueMicrotask(() => {
64
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
65
+ this.#queued = false;
66
+ this.#run();
67
+ });
68
+ }
69
+ };
70
+
21
71
  // src/controllers/data_grid_controller.ts
22
72
  var SORT_CYCLE = ["none", "ascending", "descending"];
23
73
  function nextSortDirection(current) {
@@ -32,8 +82,15 @@ var DataGridController = class extends Controller {
32
82
  };
33
83
  static actions = ["onKeydown", "sort", "toggleSelect"];
34
84
  static events = ["selectionchange", "sort"];
35
- /** Gates the row callback so it does not re-walk every row once per authored row on mount. */
36
- #connected = false;
85
+ /**
86
+ * Collapses the per-element target callbacks of one DOM mutation into a single
87
+ * baseline pass, and refuses to run before `connect()` or after `disconnect()`.
88
+ *
89
+ * Stimulus reports every target one at a time, so an ungated pass would re-walk
90
+ * the whole grid once per authored cell on mount and once per streamed cell
91
+ * afterwards — quadratic in the cell count both times.
92
+ */
93
+ #reconcile = new MicrotaskCoalescer(() => this.#restoreBaseline());
37
94
  /**
38
95
  * Establishes a single tab stop across all navigable cells/headers and brings
39
96
  * the rows to their baseline.
@@ -44,15 +101,30 @@ var DataGridController = class extends Controller {
44
101
  * attribute, so the Value callback does not fire a second time.
45
102
  */
46
103
  connect() {
104
+ this.#restoreBaseline();
105
+ this.#reconcile.activate();
106
+ }
107
+ /** Closes the reconcile window so a queued pass cannot run against a detached tree. */
108
+ disconnect() {
109
+ this.#reconcile.cancel();
110
+ }
111
+ /**
112
+ * Rebuilds both DOM-owned baselines from the live grid: exactly one navigable
113
+ * cell is in the Tab sequence, and every selectable row carries an explicit
114
+ * `aria-selected`.
115
+ *
116
+ * The tab stop keeps whichever cell already holds it, so a rebuild triggered by
117
+ * an unrelated row arriving does not throw the user's position away; only when
118
+ * no cell holds it — the grid is fresh, or the holder was removed — does the
119
+ * first navigable cell take over. Without that fallback a grid whose active row
120
+ * is removed keeps every cell at `-1` and drops out of the Tab sequence
121
+ * entirely.
122
+ */
123
+ #restoreBaseline() {
47
124
  const cells = this.#navigableCells();
48
125
  const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
49
- this.#setActiveCell(active, { focus: false });
126
+ if (active) this.#setActiveCell(active, { focus: false }, cells);
50
127
  this.#normalizeSelection();
51
- this.#connected = true;
52
- }
53
- /** Reopens the row callback for the next mount. */
54
- disconnect() {
55
- this.#connected = false;
56
128
  }
57
129
  /**
58
130
  * Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
@@ -63,16 +135,25 @@ var DataGridController = class extends Controller {
63
135
  this.#syncSelectable();
64
136
  this.#normalizeSelection();
65
137
  }
66
- /**
67
- * Re-establishes the row baseline for a row added after connect.
68
- *
69
- * Each pass walks every row, and Stimulus reports the authored rows one by one
70
- * before `connect()`, so the mount is gated to keep it linear in the row count
71
- * rather than quadratic; `connect()` runs the single baseline pass instead.
72
- */
138
+ /** Re-establishes the baselines for a row added after connect. */
73
139
  rowTargetConnected() {
74
- if (!this.#connected) return;
75
- this.#normalizeSelection();
140
+ this.#reconcile.schedule();
141
+ }
142
+ /** Re-establishes the tab stop when a cell joins the grid after connect. */
143
+ cellTargetConnected() {
144
+ this.#reconcile.schedule();
145
+ }
146
+ /** Re-establishes the tab stop when a cell leaves the grid. */
147
+ cellTargetDisconnected() {
148
+ this.#reconcile.schedule();
149
+ }
150
+ /** Re-establishes the tab stop when a header joins the grid after connect. */
151
+ columnHeaderTargetConnected() {
152
+ this.#reconcile.schedule();
153
+ }
154
+ /** Re-establishes the tab stop when a header leaves the grid. */
155
+ columnHeaderTargetDisconnected() {
156
+ this.#reconcile.schedule();
76
157
  }
77
158
  /**
78
159
  * Brings the authored rows to the shape the APG requires, without changing
@@ -117,6 +198,9 @@ var DataGridController = class extends Controller {
117
198
  sort(event) {
118
199
  const header = event.currentTarget;
119
200
  if (!this.columnHeaderTargets.includes(header)) return;
201
+ if (event.defaultPrevented) return;
202
+ const control = this.#claimingControl(event, header);
203
+ if (control && !(control instanceof HTMLButtonElement)) return;
120
204
  const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
121
205
  for (const other of this.columnHeaderTargets) {
122
206
  other.setAttribute("aria-sort", other === header ? direction : "none");
@@ -127,14 +211,19 @@ var DataGridController = class extends Controller {
127
211
  /** Toggles selection of the row owning the event target. Bound optionally. */
128
212
  toggleSelect(event) {
129
213
  if (this.selectionValue === "none") return;
130
- const row = event.currentTarget.closest("[role='row']");
214
+ if (event.defaultPrevented) return;
215
+ const host = event.currentTarget;
216
+ if (this.#claimedByDescendant(event, host)) return;
217
+ const row = host.closest("[role='row']");
131
218
  if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
132
219
  }
133
220
  /** Grid navigation + sort/select activation. Bound to cells and headers. */
134
221
  onKeydown(event) {
135
222
  if (event.defaultPrevented) return;
136
223
  if (isReservedArrowChord(event)) return;
224
+ if (event.isComposing) return;
137
225
  const cell = event.currentTarget;
226
+ if (this.#claimedByDescendant(event, cell)) return;
138
227
  const matrix = this.#matrix();
139
228
  const position = this.#locate(matrix, cell);
140
229
  if (!position) return;
@@ -170,9 +259,35 @@ var DataGridController = class extends Controller {
170
259
  }
171
260
  if (target) {
172
261
  event.preventDefault();
173
- this.#setActiveCell(target, { focus: true });
262
+ this.#setActiveCell(target, { focus: true }, matrix.flat());
174
263
  }
175
264
  }
265
+ /**
266
+ * Whether the event was addressed to a control inside `host` rather than to the
267
+ * grid.
268
+ *
269
+ * Cells and headers hold consumer markup, and APG's grid pattern expects that
270
+ * markup to include working controls — a row action button, an inline editor.
271
+ * Those own their own keystrokes and clicks, so the grid stands down entirely
272
+ * rather than acting in parallel. An editable host (its `contenteditable` state
273
+ * is inherited, so the walk is explicit) counts the same way.
274
+ */
275
+ #claimedByDescendant(event, host) {
276
+ return this.#claimingControl(event, host) !== null;
277
+ }
278
+ /**
279
+ * The nested control this event belongs to, or `null` when the host owns it.
280
+ *
281
+ * Naming the control, rather than answering yes or no, is what lets the click
282
+ * path treat a sortable header's `<button>` as the activation it is while every
283
+ * other control still takes the event away.
284
+ */
285
+ #claimingControl(event, host) {
286
+ const source = event.target;
287
+ const control = source.closest(INTERACTIVE_HOST_SELECTOR);
288
+ if (control && host.contains(control)) return control;
289
+ return isInteractiveHost(source) ? source : null;
290
+ }
176
291
  /** Performs a header's sort or a cell row's selection toggle on activation. */
177
292
  #activate(cell) {
178
293
  if (this.columnHeaderTargets.includes(cell)) {
@@ -203,11 +318,22 @@ var DataGridController = class extends Controller {
203
318
  const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
204
319
  this.dispatch("selectionchange", { detail: { rows } });
205
320
  }
206
- /** Makes `cell` the single tabbable cell (roving) and optionally focuses it. */
207
- #setActiveCell(cell, { focus }) {
208
- if (!cell) return;
209
- for (const candidate of this.#navigableCells()) {
210
- candidate.tabIndex = candidate === cell ? 0 : -1;
321
+ /**
322
+ * Makes `cell` the single tabbable cell (roving) and optionally focuses it.
323
+ *
324
+ * `cells` lets a caller that already walked the grid hand its collection over,
325
+ * so one keystroke rebuilds the matrix once instead of twice. The write is
326
+ * skipped where the attribute already holds the wanted value — comparing the
327
+ * attribute rather than the IDL property, because a cell with no `tabindex` at
328
+ * all reports `-1` and would then never receive the attribute it needs to be
329
+ * focusable.
330
+ */
331
+ #setActiveCell(cell, { focus }, cells) {
332
+ for (const candidate of cells ?? this.#navigableCells()) {
333
+ const wanted = candidate === cell ? "0" : "-1";
334
+ if (candidate.getAttribute("tabindex") !== wanted) {
335
+ candidate.setAttribute("tabindex", wanted);
336
+ }
211
337
  }
212
338
  if (focus) cell.focus();
213
339
  }