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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/data_grid_controller.js +150 -24
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/otp_controller.js +29 -16
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/index.js +860 -339
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
/**
|
|
85
|
-
#
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
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.#
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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,
|
|
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.
|
|
219
|
-
|
|
220
|
-
for (const
|
|
221
|
-
this.
|
|
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
|
|
225
|
-
* settled on. Disabling alpha drops it from the model
|
|
226
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
36
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
}
|