stimeo-ui 0.1.0.pre.beta.3 → 0.2.1

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/README.md +6 -6
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/auto_submit_controller.js +56 -21
  6. data/dist/controllers/carousel_controller.js +272 -0
  7. data/dist/controllers/character_counter_controller.js +52 -15
  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 +213 -0
  11. data/dist/controllers/combobox_controller.js +64 -3
  12. data/dist/controllers/command_palette_controller.js +243 -39
  13. data/dist/controllers/confirm_controller.js +94 -8
  14. data/dist/controllers/context_menu_controller.js +191 -13
  15. data/dist/controllers/count_up_controller.js +8 -1
  16. data/dist/controllers/countdown_controller.js +2 -2
  17. data/dist/controllers/currency_input_controller.js +147 -0
  18. data/dist/controllers/data_grid_controller.js +168 -0
  19. data/dist/controllers/date_range_picker_controller.js +417 -0
  20. data/dist/controllers/dialog_controller.js +94 -8
  21. data/dist/controllers/dismissible_controller.js +117 -0
  22. data/dist/controllers/drawer_controller.js +630 -0
  23. data/dist/controllers/dropdown_controller.js +139 -16
  24. data/dist/controllers/editable_controller.js +168 -0
  25. data/dist/controllers/file_dropzone_controller.js +165 -0
  26. data/dist/controllers/filter_controller.js +86 -0
  27. data/dist/controllers/flash_controller.js +36 -5
  28. data/dist/controllers/focus_controller.js +94 -8
  29. data/dist/controllers/form_validation_controller.js +2 -2
  30. data/dist/controllers/highlight_controller.js +6 -4
  31. data/dist/controllers/hover_card_controller.js +103 -27
  32. data/dist/controllers/intersection_controller.js +41 -18
  33. data/dist/controllers/lazy_frame_controller.js +33 -11
  34. data/dist/controllers/listbox_controller.js +1 -0
  35. data/dist/controllers/masonry_controller.js +142 -0
  36. data/dist/controllers/menu_controller.js +181 -8
  37. data/dist/controllers/menubar_controller.js +433 -0
  38. data/dist/controllers/multi_select_controller.js +472 -0
  39. data/dist/controllers/navigation_menu_controller.js +384 -0
  40. data/dist/controllers/otp_controller.js +62 -22
  41. data/dist/controllers/overflow_indicator_controller.js +178 -27
  42. data/dist/controllers/password_reveal_controller.js +117 -0
  43. data/dist/controllers/pointer_drag_controller.js +3 -3
  44. data/dist/controllers/popover_controller.js +118 -27
  45. data/dist/controllers/range_slider_controller.js +166 -0
  46. data/dist/controllers/read_more_controller.js +194 -0
  47. data/dist/controllers/scroll_area_controller.js +15 -2
  48. data/dist/controllers/scroll_restore_controller.js +93 -0
  49. data/dist/controllers/scroll_visibility_controller.js +8 -4
  50. data/dist/controllers/scrollspy_controller.js +33 -11
  51. data/dist/controllers/separator_controller.js +87 -0
  52. data/dist/controllers/sidebar_controller.js +761 -0
  53. data/dist/controllers/stepper_controller.js +28 -12
  54. data/dist/controllers/stick_to_bottom_controller.js +8 -4
  55. data/dist/controllers/sticky_observer_controller.js +88 -20
  56. data/dist/controllers/tags_input_controller.js +275 -0
  57. data/dist/controllers/theme_controller.js +20 -10
  58. data/dist/controllers/time_picker_controller.js +212 -0
  59. data/dist/controllers/toast_controller.js +144 -38
  60. data/dist/controllers/tooltip_controller.js +148 -42
  61. data/dist/controllers/transition_controller.js +153 -38
  62. data/dist/controllers/tree_view_controller.js +275 -0
  63. data/dist/index.js +1644 -643
  64. data/lib/stimeo/ui/version.rb +5 -4
  65. metadata +28 -2
@@ -1,17 +1,171 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/context_menu_controller.ts
4
+
5
+ // src/utils/escape_layer.ts
6
+ function claimsWhileFocusWithin(element) {
7
+ return () => {
8
+ const active = element.ownerDocument.activeElement;
9
+ return active === null || active === element.ownerDocument.body || element.contains(active);
10
+ };
11
+ }
12
+ var EscapeLayer = class _EscapeLayer {
13
+ static #registries = /* @__PURE__ */ new WeakMap();
14
+ #ownerDocument = null;
15
+ /** Dismissal callback while active; `null` when inactive. */
16
+ #onDismiss = null;
17
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
18
+ #claims = null;
19
+ /**
20
+ * Activates this layer at the top of its document's Escape stack, installing
21
+ * the document's shared resolver listener if this is its first layer.
22
+ * Re-activating an already-active layer moves it to the top.
23
+ */
24
+ activate(ownerDocument = document, options) {
25
+ this.deactivate();
26
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
27
+ if (!registry) {
28
+ registry = _EscapeLayer.#createRegistry();
29
+ _EscapeLayer.#registries.set(ownerDocument, registry);
30
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
31
+ }
32
+ registry.stack.push(this);
33
+ this.#ownerDocument = ownerDocument;
34
+ this.#onDismiss = options.onDismiss;
35
+ this.#claims = options.claims ?? null;
36
+ }
37
+ /**
38
+ * Removes this layer from its document's Escape stack, uninstalling the
39
+ * shared listener when the stack empties. Safe to call when inactive.
40
+ */
41
+ deactivate() {
42
+ const ownerDocument = this.#ownerDocument;
43
+ if (!ownerDocument) return;
44
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
45
+ if (registry) {
46
+ const index = registry.stack.lastIndexOf(this);
47
+ if (index >= 0) registry.stack.splice(index, 1);
48
+ if (registry.stack.length === 0) {
49
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
50
+ _EscapeLayer.#registries.delete(ownerDocument);
51
+ }
52
+ }
53
+ this.#ownerDocument = null;
54
+ this.#onDismiss = null;
55
+ this.#claims = null;
56
+ }
57
+ /**
58
+ * Whether this active layer would own a press right now: it is the topmost
59
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
60
+ * and diagnostics — production dismissal goes through the shared listener.
61
+ */
62
+ get ownsEscape() {
63
+ const ownerDocument = this.#ownerDocument;
64
+ if (!ownerDocument) return false;
65
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
66
+ if (!registry) return false;
67
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
68
+ }
69
+ /** Builds a document's registry with its shared resolver listener. */
70
+ static #createRegistry() {
71
+ const registry = {
72
+ stack: [],
73
+ onKeydown: (event) => {
74
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
75
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
76
+ if (!owner) return;
77
+ event.preventDefault();
78
+ owner.#onDismiss?.();
79
+ }
80
+ };
81
+ return registry;
82
+ }
83
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
84
+ static #resolveOwner(stack) {
85
+ for (let index = stack.length - 1; index >= 0; index--) {
86
+ const layer = stack[index];
87
+ if (!layer) continue;
88
+ if (layer.#claims && !layer.#claims()) continue;
89
+ return layer;
90
+ }
91
+ return null;
92
+ }
93
+ };
94
+
95
+ // src/utils/safe_timeout.ts
96
+ var TimerRegistry = class {
97
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
98
+ ids = /* @__PURE__ */ new Set();
99
+ /**
100
+ * Cancels a single tracked timer.
101
+ *
102
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
103
+ * registry), so callers can clear defensively without guarding.
104
+ */
105
+ clear(id) {
106
+ if (this.ids.delete(id)) {
107
+ this.cancel(id);
108
+ }
109
+ }
110
+ /**
111
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
112
+ * to guarantee no timer outlives the element.
113
+ */
114
+ clearAll() {
115
+ for (const id of this.ids) {
116
+ this.cancel(id);
117
+ }
118
+ this.ids.clear();
119
+ }
120
+ /** Number of timers currently tracked (pending). */
121
+ get size() {
122
+ return this.ids.size;
123
+ }
124
+ };
125
+ var SafeTimeout = class extends TimerRegistry {
126
+ /**
127
+ * Schedules `callback` after `delay` ms and returns the timer id.
128
+ *
129
+ * The id is removed from the registry automatically when the timeout fires,
130
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
131
+ */
132
+ set(callback, delay) {
133
+ const id = this.schedule(() => {
134
+ this.ids.delete(id);
135
+ callback();
136
+ }, delay);
137
+ this.ids.add(id);
138
+ return id;
139
+ }
140
+ schedule(callback, delay) {
141
+ return window.setTimeout(callback, delay);
142
+ }
143
+ cancel(id) {
144
+ window.clearTimeout(id);
145
+ }
146
+ };
147
+
3
148
  // src/controllers/context_menu_controller.ts
4
149
  var ContextMenuController = class extends Controller {
5
150
  static targets = ["region", "menu", "item"];
6
151
  static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
7
- /** Starts closed and registers the outside-click listener. */
152
+ #timers = new SafeTimeout();
153
+ /** Escape-stack membership while open; the shared resolver dismisses via it. */
154
+ #escapeLayer = new EscapeLayer();
155
+ /** Starts closed and registers delegated activation and outside-pointer listeners. */
8
156
  connect() {
9
157
  this.#closeMenu();
10
- document.addEventListener("click", this.#onOutsideClick);
158
+ this.element.addEventListener("click", this.#onItemClickCapture, true);
159
+ document.addEventListener("click", this.#onOutsidePointer);
160
+ document.addEventListener("contextmenu", this.#onOutsidePointer);
11
161
  }
12
- /** Removes the document-level listener registered in {@link connect}. */
162
+ /** Releases the listeners, stack membership, and pending Tab-close task. */
13
163
  disconnect() {
14
- document.removeEventListener("click", this.#onOutsideClick);
164
+ this.#timers.clearAll();
165
+ this.#escapeLayer.deactivate();
166
+ this.element.removeEventListener("click", this.#onItemClickCapture, true);
167
+ document.removeEventListener("click", this.#onOutsidePointer);
168
+ document.removeEventListener("contextmenu", this.#onOutsidePointer);
15
169
  }
16
170
  /**
17
171
  * Opens the menu from a `contextmenu` event: suppresses the native menu and
@@ -36,11 +190,17 @@ var ContextMenuController = class extends Controller {
36
190
  switch (event.key) {
37
191
  case "ArrowDown":
38
192
  event.preventDefault();
39
- if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();
193
+ if (items.length > 0) {
194
+ const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
195
+ items[nextIndex]?.focus();
196
+ }
40
197
  break;
41
198
  case "ArrowUp":
42
199
  event.preventDefault();
43
- if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();
200
+ if (items.length > 0) {
201
+ const previousIndex = currentIndex < 0 ? items.length - 1 : currentIndex - 1;
202
+ items[(previousIndex + items.length) % items.length]?.focus();
203
+ }
44
204
  break;
45
205
  case "Home":
46
206
  event.preventDefault();
@@ -50,12 +210,9 @@ var ContextMenuController = class extends Controller {
50
210
  event.preventDefault();
51
211
  items[items.length - 1]?.focus();
52
212
  break;
53
- case "Escape":
54
- event.preventDefault();
55
- this.#closeAndRestore();
56
- break;
57
213
  case "Tab":
58
- this.#closeMenu();
214
+ this.#timers.clearAll();
215
+ this.#timers.set(() => this.#closeMenu(), 0);
59
216
  break;
60
217
  }
61
218
  }
@@ -66,6 +223,11 @@ var ContextMenuController = class extends Controller {
66
223
  /** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
67
224
  #openAt(x, y) {
68
225
  if (!this.hasMenuTarget) return;
226
+ this.#timers.clearAll();
227
+ this.#escapeLayer.activate(document, {
228
+ onDismiss: () => this.#closeAndRestore(),
229
+ claims: claimsWhileFocusWithin(this.element)
230
+ });
69
231
  this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
70
232
  this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
71
233
  this.menuTarget.hidden = false;
@@ -74,6 +236,8 @@ var ContextMenuController = class extends Controller {
74
236
  }
75
237
  /** Hides the menu and reflects the collapsed state on the region. */
76
238
  #closeMenu() {
239
+ this.#timers.clearAll();
240
+ this.#escapeLayer.deactivate();
77
241
  if (!this.hasMenuTarget) return;
78
242
  this.menuTarget.hidden = true;
79
243
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
@@ -83,10 +247,24 @@ var ContextMenuController = class extends Controller {
83
247
  this.#closeMenu();
84
248
  if (this.hasRegionTarget) this.regionTarget.focus();
85
249
  }
86
- /** Closes the menu when a click lands outside the controller's element. */
87
- #onOutsideClick = (event) => {
250
+ /** Closes when a click or context-menu invocation lands outside this instance. */
251
+ #onOutsidePointer = (event) => {
88
252
  if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
89
253
  };
254
+ /**
255
+ * Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
256
+ * Native Enter/Space activation also synthesizes a click and is blocked here.
257
+ */
258
+ #onItemClickCapture = (event) => {
259
+ const target = event.target;
260
+ if (!(target instanceof Node)) return;
261
+ const disabled = this.itemTargets.some(
262
+ (item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
263
+ );
264
+ if (!disabled) return;
265
+ event.preventDefault();
266
+ event.stopImmediatePropagation();
267
+ };
90
268
  /** Menu items eligible for roving focus (excludes disabled / hidden). */
91
269
  get #navigableItems() {
92
270
  return this.itemTargets.filter((item) => this.#isNavigable(item));
@@ -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;
@@ -107,8 +107,8 @@ var CountdownController = class extends Controller {
107
107
  * paused (or completed) one resets the displayed amount but stays paused until the
108
108
  * user resumes — it never silently restarts. The run state is read from the DOM,
109
109
  * not re-derived from the declarative `autostart` Value (which only governs the
110
- * initial state on connect); re-deriving it would override a user's pause, the same
111
- * anti-pattern the Turbo-lifecycle guide warns about.
110
+ * initial state on connect); re-deriving it would override a user's pause —
111
+ * the DOM, not a re-run of declarative config, is the source of truth.
112
112
  */
113
113
  reset() {
114
114
  const wasRunning = this.#state === "running";
@@ -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,168 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/data_grid_controller.ts
4
+ var SORT_CYCLE = ["none", "ascending", "descending"];
5
+ function nextSortDirection(current) {
6
+ const index = SORT_CYCLE.indexOf(current);
7
+ const from = index < 0 ? 0 : index;
8
+ return SORT_CYCLE[(from + 1) % SORT_CYCLE.length] ?? "ascending";
9
+ }
10
+ var DataGridController = class extends Controller {
11
+ static targets = ["columnHeader", "row", "cell"];
12
+ static values = {
13
+ selection: { type: String, default: "none" }
14
+ };
15
+ static actions = ["onKeydown", "sort", "toggleSelect"];
16
+ static events = ["selectionchange", "sort"];
17
+ /** Establishes a single tab stop across all navigable cells/headers. */
18
+ connect() {
19
+ const cells = this.#navigableCells();
20
+ const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
21
+ this.#setActiveCell(active, { focus: false });
22
+ }
23
+ /**
24
+ * Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
25
+ * (so it self-heals after a Turbo morph) and on any runtime change, so the ARIA
26
+ * never drifts from the selection logic, which reads `selectionValue` live.
27
+ */
28
+ selectionValueChanged() {
29
+ this.#syncSelectable();
30
+ }
31
+ /**
32
+ * Mirrors `selection="multiple"` onto `aria-multiselectable` (APG Grid) so SRs
33
+ * announce that more than one row can be selected; cleared for single/none so a
34
+ * grid never carries a misleading attribute.
35
+ */
36
+ #syncSelectable() {
37
+ if (this.selectionValue === "multiple") {
38
+ this.element.setAttribute("aria-multiselectable", "true");
39
+ } else {
40
+ this.element.removeAttribute("aria-multiselectable");
41
+ }
42
+ }
43
+ /** Cycles the activated column header's sort and emits `sort`. */
44
+ sort(event) {
45
+ const header = event.currentTarget;
46
+ if (!this.columnHeaderTargets.includes(header)) return;
47
+ const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
48
+ for (const other of this.columnHeaderTargets) {
49
+ other.setAttribute("aria-sort", other === header ? direction : "none");
50
+ }
51
+ this.#setActiveCell(header, { focus: false });
52
+ this.dispatch("sort", { detail: { column: header, direction } });
53
+ }
54
+ /** Toggles selection of the row owning the event target. Bound optionally. */
55
+ toggleSelect(event) {
56
+ const row = event.currentTarget.closest("[role='row']");
57
+ if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
58
+ }
59
+ /** Grid navigation + sort/select activation. Bound to cells and headers. */
60
+ onKeydown(event) {
61
+ const cell = event.currentTarget;
62
+ const matrix = this.#matrix();
63
+ const position = this.#locate(matrix, cell);
64
+ if (!position) return;
65
+ const [row, col] = position;
66
+ const rowCells = matrix[row] ?? [];
67
+ let target;
68
+ switch (event.key) {
69
+ case "ArrowRight":
70
+ target = this.#cellInRow(matrix, row, col + 1);
71
+ break;
72
+ case "ArrowLeft":
73
+ target = this.#cellInRow(matrix, row, Math.max(col - 1, 0));
74
+ break;
75
+ case "ArrowDown":
76
+ target = this.#cellInRow(matrix, Math.min(row + 1, matrix.length - 1), col);
77
+ break;
78
+ case "ArrowUp":
79
+ target = this.#cellInRow(matrix, Math.max(row - 1, 0), col);
80
+ break;
81
+ case "Home":
82
+ target = event.ctrlKey ? this.#cellInRow(matrix, 0, 0) : rowCells[0];
83
+ break;
84
+ case "End":
85
+ target = event.ctrlKey ? this.#cellInRow(matrix, matrix.length - 1, Number.POSITIVE_INFINITY) : rowCells[rowCells.length - 1];
86
+ break;
87
+ case "Enter":
88
+ case " ":
89
+ this.#activate(cell);
90
+ event.preventDefault();
91
+ return;
92
+ default:
93
+ return;
94
+ }
95
+ if (target) {
96
+ event.preventDefault();
97
+ this.#setActiveCell(target, { focus: true });
98
+ }
99
+ }
100
+ /** Performs a header's sort or a cell row's selection toggle on activation. */
101
+ #activate(cell) {
102
+ if (this.columnHeaderTargets.includes(cell)) {
103
+ this.#cycleSort(cell);
104
+ return;
105
+ }
106
+ if (this.selectionValue === "none") return;
107
+ const row = cell.closest("[role='row']");
108
+ if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
109
+ }
110
+ /** Shared sort logic for both click and keyboard activation. */
111
+ #cycleSort(header) {
112
+ const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
113
+ for (const other of this.columnHeaderTargets) {
114
+ other.setAttribute("aria-sort", other === header ? direction : "none");
115
+ }
116
+ this.dispatch("sort", { detail: { column: header, direction } });
117
+ }
118
+ /** Toggles a row's `aria-selected`, honoring single vs. multiple selection. */
119
+ #toggleRow(row) {
120
+ const selected = row.getAttribute("aria-selected") === "true";
121
+ if (this.selectionValue === "single" && !selected) {
122
+ for (const other of this.rowTargets) other.setAttribute("aria-selected", "false");
123
+ }
124
+ row.setAttribute("aria-selected", selected ? "false" : "true");
125
+ const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
126
+ this.dispatch("selectionchange", { detail: { rows } });
127
+ }
128
+ /** Makes `cell` the single tabbable cell (roving) and optionally focuses it. */
129
+ #setActiveCell(cell, { focus }) {
130
+ if (!cell) return;
131
+ for (const candidate of this.#navigableCells()) {
132
+ candidate.tabIndex = candidate === cell ? 0 : -1;
133
+ }
134
+ if (focus) cell.focus();
135
+ }
136
+ /** All navigable elements (headers + cells) in DOM order. */
137
+ #navigableCells() {
138
+ return this.#matrix().flat();
139
+ }
140
+ /** The grid as rows of navigable cells, derived from each `role="row"`. */
141
+ #matrix() {
142
+ const navigable = /* @__PURE__ */ new Set([...this.columnHeaderTargets, ...this.cellTargets]);
143
+ const rows = Array.from(this.element.querySelectorAll("[role='row']"));
144
+ return rows.map(
145
+ (row) => Array.from(row.children).filter(
146
+ (child) => navigable.has(child)
147
+ )
148
+ ).filter((cells) => cells.length > 0);
149
+ }
150
+ /** Finds `[rowIndex, colIndex]` of `cell` within `matrix`, or null. */
151
+ #locate(matrix, cell) {
152
+ for (let row = 0; row < matrix.length; row++) {
153
+ const col = (matrix[row] ?? []).indexOf(cell);
154
+ if (col !== -1) return [row, col];
155
+ }
156
+ return null;
157
+ }
158
+ /** The cell at `[row, col]`, clamping `col` to that row's last cell. */
159
+ #cellInRow(matrix, row, col) {
160
+ const cells = matrix[row];
161
+ if (!cells || cells.length === 0) return void 0;
162
+ return cells[Math.min(col, cells.length - 1)];
163
+ }
164
+ };
165
+
166
+ export { DataGridController };
167
+ //# sourceMappingURL=data_grid_controller.js.map
168
+ //# sourceMappingURL=data_grid_controller.js.map