stimeo-ui 0.2.0 → 0.3.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/breadcrumb_controller.js +225 -13
  6. data/dist/controllers/calendar_controller.js +89 -22
  7. data/dist/controllers/carousel_controller.js +313 -0
  8. data/dist/controllers/clipboard_controller.js +144 -0
  9. data/dist/controllers/collapsible_controller.js +327 -0
  10. data/dist/controllers/color_picker_controller.js +252 -0
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +194 -17
  13. data/dist/controllers/context_menu_controller.js +32 -10
  14. data/dist/controllers/count_up_controller.js +8 -1
  15. data/dist/controllers/currency_input_controller.js +147 -0
  16. data/dist/controllers/data_grid_controller.js +246 -0
  17. data/dist/controllers/date_range_picker_controller.js +441 -0
  18. data/dist/controllers/dismissible_controller.js +117 -0
  19. data/dist/controllers/drawer_controller.js +630 -0
  20. data/dist/controllers/editable_controller.js +169 -0
  21. data/dist/controllers/file_dropzone_controller.js +165 -0
  22. data/dist/controllers/filter_controller.js +86 -0
  23. data/dist/controllers/flash_controller.js +36 -5
  24. data/dist/controllers/form_validation_controller.js +1 -1
  25. data/dist/controllers/highlight_controller.js +6 -4
  26. data/dist/controllers/intersection_controller.js +67 -19
  27. data/dist/controllers/lazy_frame_controller.js +54 -11
  28. data/dist/controllers/listbox_controller.js +257 -53
  29. data/dist/controllers/local_time_controller.js +2 -2
  30. data/dist/controllers/masonry_controller.js +142 -0
  31. data/dist/controllers/menu_controller.js +104 -17
  32. data/dist/controllers/menubar_controller.js +785 -0
  33. data/dist/controllers/multi_select_controller.js +755 -0
  34. data/dist/controllers/navigation_menu_controller.js +511 -0
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +246 -27
  38. data/dist/controllers/overflow_menu_controller.js +381 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/password_reveal_controller.js +117 -0
  41. data/dist/controllers/persist_controller.js +6 -6
  42. data/dist/controllers/pointer_drag_controller.js +9 -1
  43. data/dist/controllers/popover_controller.js +2 -2
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +192 -0
  46. data/dist/controllers/rating_controller.js +16 -2
  47. data/dist/controllers/read_more_controller.js +238 -0
  48. data/dist/controllers/resizable_controller.js +65 -1
  49. data/dist/controllers/roving_controller.js +17 -2
  50. data/dist/controllers/scroll_area_controller.js +101 -14
  51. data/dist/controllers/scroll_restore_controller.js +93 -0
  52. data/dist/controllers/scroll_visibility_controller.js +40 -6
  53. data/dist/controllers/scrollspy_controller.js +369 -74
  54. data/dist/controllers/separator_controller.js +96 -0
  55. data/dist/controllers/sidebar_controller.js +761 -0
  56. data/dist/controllers/skeleton_controller.js +1 -1
  57. data/dist/controllers/slider_controller.js +32 -6
  58. data/dist/controllers/sortable_controller.js +34 -3
  59. data/dist/controllers/spinner_controller.js +1 -1
  60. data/dist/controllers/stepper_controller.js +28 -12
  61. data/dist/controllers/stick_to_bottom_controller.js +9 -4
  62. data/dist/controllers/sticky_observer_controller.js +109 -20
  63. data/dist/controllers/switch_controller.js +1 -0
  64. data/dist/controllers/tabs_controller.js +26 -3
  65. data/dist/controllers/tags_input_controller.js +295 -0
  66. data/dist/controllers/theme_controller.js +42 -13
  67. data/dist/controllers/time_picker_controller.js +231 -0
  68. data/dist/controllers/toast_controller.js +40 -14
  69. data/dist/controllers/toggle_group_controller.js +23 -2
  70. data/dist/controllers/toolbar_controller.js +230 -31
  71. data/dist/controllers/transition_controller.js +153 -38
  72. data/dist/controllers/tree_view_controller.js +691 -0
  73. data/dist/index.js +4256 -915
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +28 -2
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/context_menu_controller.ts
4
4
 
5
+ // src/utils/arrow_step.ts
6
+ function isReservedArrowChord(event, allow = []) {
7
+ if (!event.key.startsWith("Arrow")) return false;
8
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
+ }
10
+
5
11
  // src/utils/escape_layer.ts
6
12
  function claimsWhileFocusWithin(element) {
7
13
  return () => {
@@ -156,16 +162,16 @@ var ContextMenuController = class extends Controller {
156
162
  connect() {
157
163
  this.#closeMenu();
158
164
  this.element.addEventListener("click", this.#onItemClickCapture, true);
159
- document.addEventListener("click", this.#onOutsidePointer);
160
- document.addEventListener("contextmenu", this.#onOutsidePointer);
165
+ document.addEventListener("click", this.#onOutsidePointer, true);
166
+ document.addEventListener("contextmenu", this.#onOutsidePointer, true);
161
167
  }
162
168
  /** Releases the listeners, stack membership, and pending Tab-close task. */
163
169
  disconnect() {
164
170
  this.#timers.clearAll();
165
171
  this.#escapeLayer.deactivate();
166
172
  this.element.removeEventListener("click", this.#onItemClickCapture, true);
167
- document.removeEventListener("click", this.#onOutsidePointer);
168
- document.removeEventListener("contextmenu", this.#onOutsidePointer);
173
+ document.removeEventListener("click", this.#onOutsidePointer, true);
174
+ document.removeEventListener("contextmenu", this.#onOutsidePointer, true);
169
175
  }
170
176
  /**
171
177
  * Opens the menu from a `contextmenu` event: suppresses the native menu and
@@ -177,6 +183,7 @@ var ContextMenuController = class extends Controller {
177
183
  }
178
184
  /** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */
179
185
  onRegionKeydown(event) {
186
+ if (event.defaultPrevented) return;
180
187
  const isContextKey = event.key === "ContextMenu" || event.key === "F10" && event.shiftKey;
181
188
  if (!isContextKey) return;
182
189
  event.preventDefault();
@@ -185,6 +192,8 @@ var ContextMenuController = class extends Controller {
185
192
  }
186
193
  /** Roving focus and closing keys inside the menu. */
187
194
  onItemKeydown(event) {
195
+ if (event.defaultPrevented) return;
196
+ if (isReservedArrowChord(event)) return;
188
197
  const items = this.#navigableItems;
189
198
  const currentIndex = items.indexOf(event.currentTarget);
190
199
  switch (event.key) {
@@ -247,7 +256,13 @@ var ContextMenuController = class extends Controller {
247
256
  this.#closeMenu();
248
257
  if (this.hasRegionTarget) this.regionTarget.focus();
249
258
  }
250
- /** Closes when a click or context-menu invocation lands outside this instance. */
259
+ /**
260
+ * Closes when a click or context-menu invocation lands outside this instance.
261
+ * Both subscriptions observe in the capture phase, so the target is judged
262
+ * against the tree the user actually pressed even when a consumer handler
263
+ * detaches it, and application code that stops bubbling cannot leave the menu
264
+ * open.
265
+ */
251
266
  #onOutsidePointer = (event) => {
252
267
  if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
253
268
  };
@@ -265,18 +280,25 @@ var ContextMenuController = class extends Controller {
265
280
  event.preventDefault();
266
281
  event.stopImmediatePropagation();
267
282
  };
268
- /** Menu items eligible for roving focus (excludes disabled / hidden). */
283
+ /** Menu items eligible for roving focus (excludes hidden / natively disabled). */
269
284
  get #navigableItems() {
270
285
  return this.itemTargets.filter((item) => this.#isNavigable(item));
271
286
  }
272
287
  /**
273
- * An item can take roving focus unless it is `hidden`, `aria-disabled="true"`,
274
- * or a natively `disabled` form control. CSS-only visibility is not detectable
275
- * here and is the consumer's responsibility.
288
+ * An item can take roving focus unless it is `hidden` or a natively `disabled`
289
+ * form control. CSS-only visibility is not detectable here and is the
290
+ * consumer's responsibility.
291
+ *
292
+ * **`aria-disabled="true"` stays reachable.** APG separates the two attributes
293
+ * by intent: `disabled` is for controls whose existence can be inferred from a
294
+ * neighbour (a greyed Next next to a Prev), while `aria-disabled` marks a
295
+ * control that must stay *discoverable* — and it names menu items as the
296
+ * example. Skipping it would hide the command's existence from a keyboard user
297
+ * entirely. Activation is suppressed separately, so the item announces itself
298
+ * and does nothing.
276
299
  */
277
300
  #isNavigable(item) {
278
301
  if (item.hasAttribute("hidden")) return false;
279
- if (item.getAttribute("aria-disabled") === "true") return false;
280
302
  return !item.disabled;
281
303
  }
282
304
  /** Whether the menu is currently visible. */
@@ -1,5 +1,12 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/count_up_controller.ts
4
+
5
+ // src/utils/reduced_motion.ts
6
+ function prefersReducedMotion() {
7
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
+ }
9
+
3
10
  // src/controllers/count_up_controller.ts
4
11
  var CountUpController = class extends Controller {
5
12
  static values = {
@@ -32,7 +39,7 @@ var CountUpController = class extends Controller {
32
39
  this.#finalText = this.element.textContent ?? "";
33
40
  const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, ""), 10);
34
41
  if (Number.isNaN(target)) return;
35
- if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
42
+ if (prefersReducedMotion()) {
36
43
  this.element.setAttribute("data-count-up-done", "true");
37
44
  this.dispatch("end", { detail: { value: target } });
38
45
  return;
@@ -0,0 +1,147 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/currency_input_controller.ts
4
+ var CurrencyInputController = class extends Controller {
5
+ static targets = ["display", "field", "srValue"];
6
+ static values = {
7
+ locale: { type: String, default: "en-US" },
8
+ currency: { type: String, default: "" },
9
+ precision: { type: Number, default: 2 }
10
+ };
11
+ static actions = ["format", "onInput"];
12
+ static events = ["change"];
13
+ /** Last committed numeric value, to suppress duplicate `change` dispatches. */
14
+ #lastValue = null;
15
+ /** Normalizes any pre-filled display value to its fixed-precision form. */
16
+ connect() {
17
+ if (!this.hasDisplayTarget) return;
18
+ const parsed = this.#parse(this.displayTarget.value);
19
+ this.#lastValue = parsed === null ? null : round(parsed, this.precisionValue);
20
+ if (this.displayTarget.value.trim() !== "") {
21
+ this.#reformat(true);
22
+ } else {
23
+ this.#reflect(null, "");
24
+ }
25
+ }
26
+ /** Re-groups digits as the user types, preserving the caret position. */
27
+ onInput() {
28
+ this.#reformat(false);
29
+ }
30
+ /** Applies the fixed-precision rounding on blur. */
31
+ format() {
32
+ this.#reformat(true);
33
+ }
34
+ /**
35
+ * Parses the display value, rewrites it grouped (optionally at fixed
36
+ * precision), keeps the caret stable by digit count, and syncs the field,
37
+ * the screen-reader span, and the `change` event.
38
+ */
39
+ #reformat(fixedPrecision) {
40
+ if (!this.hasDisplayTarget) return;
41
+ const raw = this.displayTarget.value;
42
+ const number = this.#parse(raw);
43
+ if (number === null) {
44
+ this.displayTarget.value = "";
45
+ this.#reflect(null, "");
46
+ return;
47
+ }
48
+ const value = fixedPrecision ? round(number, this.precisionValue) : number;
49
+ const caret = this.displayTarget.selectionStart;
50
+ const digitsBeforeCaret = typeof caret === "number" ? countDigits(raw.slice(0, caret)) : null;
51
+ const formatted = this.#formatNumber(value, fixedPrecision);
52
+ this.displayTarget.value = formatted;
53
+ if (digitsBeforeCaret !== null) this.#restoreCaret(formatted, digitsBeforeCaret);
54
+ this.#reflect(value, formatted);
55
+ }
56
+ /** Restores the caret to sit just after the n-th digit of the new string. */
57
+ #restoreCaret(formatted, digitsBefore) {
58
+ let seen = 0;
59
+ let position = formatted.length;
60
+ for (let i = 0; i < formatted.length; i++) {
61
+ if (seen >= digitsBefore) {
62
+ position = i;
63
+ break;
64
+ }
65
+ if (/\d/.test(formatted[i])) seen += 1;
66
+ }
67
+ try {
68
+ this.displayTarget.setSelectionRange(position, position);
69
+ } catch {
70
+ }
71
+ }
72
+ /** Writes the normalized value to the field, the SR span, and `change`. */
73
+ #reflect(value, formatted) {
74
+ const isEmpty = value === null;
75
+ if (this.hasFieldTarget) this.fieldTarget.value = isEmpty ? "" : String(value);
76
+ if (this.hasSrValueTarget) {
77
+ this.srValueTarget.textContent = isEmpty ? "" : this.#accessibleText(value);
78
+ }
79
+ this.element.toggleAttribute("data-stimeo--currency-input-empty", isEmpty);
80
+ if (value !== this.#lastValue) {
81
+ this.#lastValue = value;
82
+ if (!isEmpty) this.dispatch("change", { detail: { value, formatted } });
83
+ }
84
+ }
85
+ /** Parses arbitrary input text into a finite number, or `null` when blank. */
86
+ #parse(text) {
87
+ if (text.trim() === "") return null;
88
+ const { decimal } = this.#separators();
89
+ let cleaned = "";
90
+ let sawDot = false;
91
+ for (let i = 0; i < text.length; i++) {
92
+ const ch = text[i];
93
+ if (ch >= "0" && ch <= "9") cleaned += ch;
94
+ else if ((ch === "-" || ch === "+") && cleaned === "") cleaned += ch;
95
+ else if ((ch === decimal || ch === ".") && !sawDot) {
96
+ cleaned += ".";
97
+ sawDot = true;
98
+ }
99
+ }
100
+ if (cleaned === "" || cleaned === "-" || cleaned === "+" || cleaned === ".") return null;
101
+ const value = Number(cleaned);
102
+ return Number.isFinite(value) ? value : null;
103
+ }
104
+ /** Formats a number with grouping for the display field. */
105
+ #formatNumber(value, fixedPrecision) {
106
+ const formatter = new Intl.NumberFormat(this.localeValue, {
107
+ useGrouping: true,
108
+ minimumFractionDigits: fixedPrecision ? this.precisionValue : 0,
109
+ maximumFractionDigits: this.precisionValue
110
+ });
111
+ return formatter.format(value);
112
+ }
113
+ /** The text announced to assistive tech (currency-aware when configured). */
114
+ #accessibleText(value) {
115
+ if (this.currencyValue) {
116
+ return new Intl.NumberFormat(this.localeValue, {
117
+ style: "currency",
118
+ currency: this.currencyValue
119
+ }).format(value);
120
+ }
121
+ return new Intl.NumberFormat(this.localeValue, {
122
+ minimumFractionDigits: this.precisionValue,
123
+ maximumFractionDigits: this.precisionValue
124
+ }).format(value);
125
+ }
126
+ /** Resolves the locale's grouping and decimal separator characters. */
127
+ #separators() {
128
+ const parts = new Intl.NumberFormat(this.localeValue).formatToParts(11111.1);
129
+ const group = parts.find((p) => p.type === "group")?.value ?? ",";
130
+ const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
131
+ return { group, decimal };
132
+ }
133
+ };
134
+ function countDigits(text) {
135
+ let count = 0;
136
+ for (const ch of text) if (ch >= "0" && ch <= "9") count += 1;
137
+ return count;
138
+ }
139
+ function round(value, precision) {
140
+ const factor = 10 ** Math.max(0, precision);
141
+ const rounded = Math.round(value * factor) / factor;
142
+ return rounded === 0 ? 0 : rounded;
143
+ }
144
+
145
+ export { CurrencyInputController };
146
+ //# sourceMappingURL=currency_input_controller.js.map
147
+ //# sourceMappingURL=currency_input_controller.js.map
@@ -0,0 +1,246 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/data_grid_controller.ts
4
+
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function logicalArrowKey(key, element) {
12
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
13
+ if (!isRtl(element)) return key;
14
+ return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
15
+ }
16
+ function isReservedArrowChord(event, allow = []) {
17
+ if (!event.key.startsWith("Arrow")) return false;
18
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
+ }
20
+
21
+ // src/controllers/data_grid_controller.ts
22
+ var SORT_CYCLE = ["none", "ascending", "descending"];
23
+ function nextSortDirection(current) {
24
+ const index = SORT_CYCLE.indexOf(current);
25
+ const from = index < 0 ? 0 : index;
26
+ return SORT_CYCLE[(from + 1) % SORT_CYCLE.length] ?? "ascending";
27
+ }
28
+ var DataGridController = class extends Controller {
29
+ static targets = ["columnHeader", "row", "cell"];
30
+ static values = {
31
+ selection: { type: String, default: "none" }
32
+ };
33
+ static actions = ["onKeydown", "sort", "toggleSelect"];
34
+ 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;
37
+ /**
38
+ * Establishes a single tab stop across all navigable cells/headers and brings
39
+ * the rows to their baseline.
40
+ *
41
+ * Normalizing here rather than leaving it to {@link selectionValueChanged}
42
+ * guarantees exactly one pass per mount: a re-attached element reuses its
43
+ * cached Stimulus context, whose value observer already knows the `selection`
44
+ * attribute, so the Value callback does not fire a second time.
45
+ */
46
+ connect() {
47
+ const cells = this.#navigableCells();
48
+ const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
49
+ this.#setActiveCell(active, { focus: false });
50
+ this.#normalizeSelection();
51
+ this.#connected = true;
52
+ }
53
+ /** Reopens the row callback for the next mount. */
54
+ disconnect() {
55
+ this.#connected = false;
56
+ }
57
+ /**
58
+ * Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
59
+ * (so it self-heals after a Turbo morph) and on any runtime change, so the ARIA
60
+ * never drifts from the selection logic, which reads `selectionValue` live.
61
+ */
62
+ selectionValueChanged() {
63
+ this.#syncSelectable();
64
+ this.#normalizeSelection();
65
+ }
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
+ */
73
+ rowTargetConnected() {
74
+ if (!this.#connected) return;
75
+ this.#normalizeSelection();
76
+ }
77
+ /**
78
+ * Brings the authored rows to the shape the APG requires, without changing
79
+ * which rows the author chose.
80
+ *
81
+ * Every selectable row gets an explicit value — an absent `aria-selected` means
82
+ * "not selectable" in ARIA, so a forgotten attribute hides a selectable row —
83
+ * and a single-select grid keeps at most one `true`, first in DOM order.
84
+ * A grid that declares `selection="none"` has no selectable rows, so the
85
+ * attribute is removed rather than written: in ARIA its absence is what "not
86
+ * selectable" looks like.
87
+ */
88
+ #normalizeSelection() {
89
+ const rows = this.rowTargets;
90
+ if (this.selectionValue === "none") {
91
+ for (const row of rows) row.removeAttribute("aria-selected");
92
+ return;
93
+ }
94
+ const single = this.selectionValue === "single";
95
+ const first = rows.find((row) => row.getAttribute("aria-selected") === "true");
96
+ for (const row of rows) {
97
+ if (single) {
98
+ row.setAttribute("aria-selected", row === first ? "true" : "false");
99
+ } else if (row.getAttribute("aria-selected") !== "true") {
100
+ row.setAttribute("aria-selected", "false");
101
+ }
102
+ }
103
+ }
104
+ /**
105
+ * Mirrors `selection="multiple"` onto `aria-multiselectable` (APG Grid) so SRs
106
+ * announce that more than one row can be selected; cleared for single/none so a
107
+ * grid never carries a misleading attribute.
108
+ */
109
+ #syncSelectable() {
110
+ if (this.selectionValue === "multiple") {
111
+ this.element.setAttribute("aria-multiselectable", "true");
112
+ } else {
113
+ this.element.removeAttribute("aria-multiselectable");
114
+ }
115
+ }
116
+ /** Cycles the activated column header's sort and emits `sort`. */
117
+ sort(event) {
118
+ const header = event.currentTarget;
119
+ if (!this.columnHeaderTargets.includes(header)) return;
120
+ const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
121
+ for (const other of this.columnHeaderTargets) {
122
+ other.setAttribute("aria-sort", other === header ? direction : "none");
123
+ }
124
+ this.#setActiveCell(header, { focus: false });
125
+ this.dispatch("sort", { detail: { column: header, direction } });
126
+ }
127
+ /** Toggles selection of the row owning the event target. Bound optionally. */
128
+ toggleSelect(event) {
129
+ if (this.selectionValue === "none") return;
130
+ const row = event.currentTarget.closest("[role='row']");
131
+ if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
132
+ }
133
+ /** Grid navigation + sort/select activation. Bound to cells and headers. */
134
+ onKeydown(event) {
135
+ if (event.defaultPrevented) return;
136
+ if (isReservedArrowChord(event)) return;
137
+ const cell = event.currentTarget;
138
+ const matrix = this.#matrix();
139
+ const position = this.#locate(matrix, cell);
140
+ if (!position) return;
141
+ const [row, col] = position;
142
+ const rowCells = matrix[row] ?? [];
143
+ let target;
144
+ switch (logicalArrowKey(event.key, this.element)) {
145
+ case "ArrowRight":
146
+ target = this.#cellInRow(matrix, row, col + 1);
147
+ break;
148
+ case "ArrowLeft":
149
+ target = this.#cellInRow(matrix, row, Math.max(col - 1, 0));
150
+ break;
151
+ case "ArrowDown":
152
+ target = this.#cellInRow(matrix, Math.min(row + 1, matrix.length - 1), col);
153
+ break;
154
+ case "ArrowUp":
155
+ target = this.#cellInRow(matrix, Math.max(row - 1, 0), col);
156
+ break;
157
+ case "Home":
158
+ target = event.ctrlKey ? this.#cellInRow(matrix, 0, 0) : rowCells[0];
159
+ break;
160
+ case "End":
161
+ target = event.ctrlKey ? this.#cellInRow(matrix, matrix.length - 1, Number.POSITIVE_INFINITY) : rowCells[rowCells.length - 1];
162
+ break;
163
+ case "Enter":
164
+ case " ":
165
+ this.#activate(cell);
166
+ event.preventDefault();
167
+ return;
168
+ default:
169
+ return;
170
+ }
171
+ if (target) {
172
+ event.preventDefault();
173
+ this.#setActiveCell(target, { focus: true });
174
+ }
175
+ }
176
+ /** Performs a header's sort or a cell row's selection toggle on activation. */
177
+ #activate(cell) {
178
+ if (this.columnHeaderTargets.includes(cell)) {
179
+ this.#cycleSort(cell);
180
+ return;
181
+ }
182
+ if (this.selectionValue === "none") return;
183
+ const row = cell.closest("[role='row']");
184
+ if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
185
+ }
186
+ /** Shared sort logic for both click and keyboard activation. */
187
+ #cycleSort(header) {
188
+ const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
189
+ for (const other of this.columnHeaderTargets) {
190
+ other.setAttribute("aria-sort", other === header ? direction : "none");
191
+ }
192
+ this.dispatch("sort", { detail: { column: header, direction } });
193
+ }
194
+ /** Toggles a row's `aria-selected`, honoring single vs. multiple selection. */
195
+ #toggleRow(row) {
196
+ const selected = row.getAttribute("aria-selected") === "true";
197
+ if (this.selectionValue === "single") {
198
+ for (const other of this.rowTargets) {
199
+ if (other !== row) other.setAttribute("aria-selected", "false");
200
+ }
201
+ }
202
+ row.setAttribute("aria-selected", selected ? "false" : "true");
203
+ const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
204
+ this.dispatch("selectionchange", { detail: { rows } });
205
+ }
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;
211
+ }
212
+ if (focus) cell.focus();
213
+ }
214
+ /** All navigable elements (headers + cells) in DOM order. */
215
+ #navigableCells() {
216
+ return this.#matrix().flat();
217
+ }
218
+ /** The grid as rows of navigable cells, derived from each `role="row"`. */
219
+ #matrix() {
220
+ const navigable = /* @__PURE__ */ new Set([...this.columnHeaderTargets, ...this.cellTargets]);
221
+ const rows = Array.from(this.element.querySelectorAll("[role='row']"));
222
+ return rows.map(
223
+ (row) => Array.from(row.children).filter(
224
+ (child) => navigable.has(child)
225
+ )
226
+ ).filter((cells) => cells.length > 0);
227
+ }
228
+ /** Finds `[rowIndex, colIndex]` of `cell` within `matrix`, or null. */
229
+ #locate(matrix, cell) {
230
+ for (let row = 0; row < matrix.length; row++) {
231
+ const col = (matrix[row] ?? []).indexOf(cell);
232
+ if (col !== -1) return [row, col];
233
+ }
234
+ return null;
235
+ }
236
+ /** The cell at `[row, col]`, clamping `col` to that row's last cell. */
237
+ #cellInRow(matrix, row, col) {
238
+ const cells = matrix[row];
239
+ if (!cells || cells.length === 0) return void 0;
240
+ return cells[Math.min(col, cells.length - 1)];
241
+ }
242
+ };
243
+
244
+ export { DataGridController };
245
+ //# sourceMappingURL=data_grid_controller.js.map
246
+ //# sourceMappingURL=data_grid_controller.js.map