stimeo-ui 0.7.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.
@@ -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
  }
@@ -111,10 +111,10 @@ var DirectUploadController = class extends Controller {
111
111
  announceErrorText: { type: String, default: "" },
112
112
  scope: { type: String, default: "" }
113
113
  };
114
- static events = ["progress", "done", "error"];
114
+ static events = ["progress", "done", "error", "reconcile"];
115
115
  #timeouts = new SafeTimeout();
116
116
  #rows = /* @__PURE__ */ new Map();
117
- #beforeCache = new BeforeCacheReset(() => this.#reset());
117
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
118
118
  /** The validated `scope` selector; a broken declaration falls back to `""`. */
119
119
  #scopeSelector = "";
120
120
  #onInitialize = (event) => {
@@ -314,6 +314,16 @@ var DirectUploadController = class extends Controller {
314
314
  * Returns the widget to its pre-upload state just before Turbo caches the
315
315
  * page, so the snapshot never replays rows for uploads that cannot resume.
316
316
  */
317
+ /**
318
+ * Rewinds for the snapshot and reports what that discarded. An upload in flight
319
+ * cannot survive the navigation, so a consumer mirroring the rows would keep a
320
+ * progress bar that never resolves.
321
+ */
322
+ #rewindForCache() {
323
+ const ids = [...this.#rows.keys()];
324
+ this.#reset();
325
+ if (ids.length > 0) this.dispatch("reconcile", { detail: { ids } });
326
+ }
317
327
  #reset() {
318
328
  for (const row of this.#rows.values()) row.remove();
319
329
  this.#rows.clear();
@@ -55,33 +55,64 @@ var EditableController = class extends Controller {
55
55
  static values = {
56
56
  submitOnBlur: { type: Boolean, default: true }
57
57
  };
58
- static actions = ["edit", "onBlur", "onDisplayKeydown", "onKeydown"];
58
+ static actions = ["cancel", "edit", "onDisplayKeydown", "onKeydown", "revert", "save"];
59
59
  static events = ["cancel", "change"];
60
60
  /** The value captured when edit mode began, used to detect real changes. */
61
61
  #previousValue = "";
62
+ /**
63
+ * The value the last save replaced, or `null` when there is nothing to undo.
64
+ * Cleared on connect: a value from before a page restore is unrecoverable, so
65
+ * `revert()` must not resurrect one.
66
+ */
67
+ #revertValue = null;
62
68
  /**
63
69
  * Owns IME lifecycle state for the edit surface, so a keydown that belongs to
64
70
  * a composition (cancel or confirm) is never treated as an edit command.
65
71
  */
66
72
  #composition = new CompositionTracker();
73
+ /**
74
+ * Watches focus leaving the editor from wherever it currently sits.
75
+ *
76
+ * `focusout` bubbles where `blur` does not, so one listener on the root sees
77
+ * every departure — including one from a Save or Cancel button the consumer
78
+ * placed beside the input. Binding the input alone would make the promise
79
+ * "saves wherever focus moved" true only for focus that leaves the input
80
+ * itself, and tabbing straight past an inner button would strand the editor
81
+ * open.
82
+ */
83
+ #onFocusOut = (event) => {
84
+ const from = event.target;
85
+ if (this.hasDisplayTarget && from instanceof Node && this.displayTarget.contains(from)) return;
86
+ const next = event.relatedTarget;
87
+ if (next instanceof Node && this.element.contains(next)) return;
88
+ if (this.submitOnBlurValue) this.#commit(false);
89
+ };
67
90
  /** Establishes the initial display mode (display shown, input hidden). */
68
91
  connect() {
69
92
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
93
+ this.element.addEventListener("focusout", this.#onFocusOut);
94
+ this.#revertValue = null;
70
95
  this.#setMode("display");
71
96
  }
72
- /** Releases the composition listeners so nothing outlives the element. */
97
+ /** Releases the composition and focus listeners so nothing outlives the element. */
73
98
  disconnect() {
74
99
  this.#composition.disconnect();
100
+ this.element.removeEventListener("focusout", this.#onFocusOut);
75
101
  }
76
102
  /** Tracks an input added initially or after connect (e.g. a Turbo swap). */
77
103
  inputTargetConnected(input) {
78
104
  this.#composition.observe(input);
105
+ this.#applyMode();
79
106
  }
80
107
  /** Removes composition listeners when the active input is replaced or removed. */
81
108
  inputTargetDisconnected(input) {
82
109
  this.#composition.unobserve(input);
83
110
  }
84
- /** Enters edit mode: seeds the input from the display text, focuses, selects. */
111
+ /** Re-hides or re-shows a display element that arrived after the mode was set. */
112
+ displayTargetConnected() {
113
+ this.#applyMode();
114
+ }
115
+ /** Enters edit mode: seeds the input from the declared value, focuses, selects. */
85
116
  edit() {
86
117
  if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
87
118
  this.#previousValue = this.#currentValue;
@@ -90,6 +121,27 @@ var EditableController = class extends Controller {
90
121
  this.inputTarget.focus();
91
122
  this.inputTarget.select();
92
123
  }
124
+ /** Commits the edit and returns focus to the display element. */
125
+ save() {
126
+ this.#commit(true);
127
+ }
128
+ /** Discards edits, returns to display mode, and dispatches `cancel`. */
129
+ cancel() {
130
+ if (!this.#isEditing) return;
131
+ this.#setMode("display");
132
+ if (this.hasDisplayTarget) this.displayTarget.focus();
133
+ this.dispatch("cancel", { detail: {} });
134
+ }
135
+ /**
136
+ * Puts back the value the last save replaced — for a consumer whose server
137
+ * rejected it. Silent by design: a `change` here would re-enter the same
138
+ * handler that asked for the undo. One save, one undo.
139
+ */
140
+ revert() {
141
+ if (this.#revertValue === null || this.#isEditing) return;
142
+ this.#writeValue(this.#revertValue);
143
+ this.#revertValue = null;
144
+ }
93
145
  /** Adds `F2` as an editing entry point alongside the button's native activation. */
94
146
  onDisplayKeydown(event) {
95
147
  if (event.key === "F2") {
@@ -103,56 +155,57 @@ var EditableController = class extends Controller {
103
155
  if (event.key === "Escape") {
104
156
  if (event.defaultPrevented) return;
105
157
  event.preventDefault();
106
- this.#cancel();
158
+ this.cancel();
107
159
  return;
108
160
  }
109
161
  if (event.key === "Enter") {
110
162
  if (event.defaultPrevented) return;
111
163
  if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
112
164
  event.preventDefault();
113
- this.#save(true);
165
+ this.#commit(true);
114
166
  }
115
167
  }
116
- /** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
117
- onBlur() {
118
- if (!this.#isEditing) return;
119
- if (this.submitOnBlurValue) this.#save(false);
120
- }
121
168
  /**
122
- * Returns to display mode, reflecting the input into the display text and
123
- * dispatching `change` when the value differs from where editing began.
169
+ * Returns to display mode, storing the input's value and dispatching `change`
170
+ * when it differs from where editing began.
124
171
  *
125
172
  * @param restoreFocus - Move focus back to the display element (explicit
126
- * keyboard commit) rather than honoring the user's new focus target (blur).
173
+ * commit) rather than honoring the user's new focus target (blur).
127
174
  */
128
- #save(restoreFocus) {
129
- if (!this.#isEditing) return;
130
- const value = this.inputTarget.value;
175
+ #commit(restoreFocus) {
176
+ if (!this.#isEditing || !this.hasInputTarget) return;
177
+ const value = this.inputTarget.value.trim();
131
178
  const previous = this.#previousValue;
132
- this.displayTarget.textContent = value;
179
+ this.#writeValue(value);
133
180
  this.#setMode("display");
134
- if (restoreFocus) this.displayTarget.focus();
181
+ if (restoreFocus && this.hasDisplayTarget) this.displayTarget.focus();
135
182
  if (value !== previous) {
183
+ this.#revertValue = previous;
136
184
  this.dispatch("change", { detail: { value, previous } });
137
185
  }
138
186
  }
139
- /** Discards edits, returns to display mode, and dispatches `cancel`. */
140
- #cancel() {
141
- if (!this.#isEditing) return;
142
- this.#setMode("display");
143
- this.displayTarget.focus();
144
- this.dispatch("cancel", { detail: {} });
187
+ /** Stores `value` wherever the display element declares it. */
188
+ #writeValue(value) {
189
+ if (!this.hasDisplayTarget) return;
190
+ const display = this.displayTarget;
191
+ if (display.hasAttribute("data-value")) display.dataset.value = value;
192
+ else display.textContent = value;
145
193
  }
146
- /** Toggles the `data-mode` flag and the `hidden` state of both elements. */
147
- #setMode(mode) {
148
- this.element.dataset.mode = mode;
149
- const editing = mode === "editing";
194
+ /** Derives both elements' visibility from the mode currently in the DOM. */
195
+ #applyMode() {
196
+ const editing = this.#isEditing;
150
197
  if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
151
198
  if (this.hasInputTarget) this.inputTarget.hidden = !editing;
152
199
  }
153
- /** Current display text, trimmed the value shown when not editing. */
200
+ /** Records the mode, then brings both elements in line with it. */
201
+ #setMode(mode) {
202
+ this.element.dataset.mode = mode;
203
+ this.#applyMode();
204
+ }
205
+ /** The value the display element declares, trimmed. */
154
206
  get #currentValue() {
155
- return (this.displayTarget.textContent ?? "").trim();
207
+ const display = this.displayTarget;
208
+ return (display.dataset.value ?? display.textContent ?? "").trim();
156
209
  }
157
210
  /** Whether the editing control is a multi-line `<textarea>`. */
158
211
  get #isMultiline() {