stimeo-ui 0.15.0 → 0.16.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +54 -10
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +56 -11
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +14 -1
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +305 -46
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -89,6 +89,47 @@ function prefersReducedMotion() {
89
89
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
90
90
  }
91
91
 
92
+ // src/utils/state_regions.ts
93
+ var StateRegions = class {
94
+ #whenTrue;
95
+ #whenFalse;
96
+ #taken = /* @__PURE__ */ new WeakSet();
97
+ constructor(options) {
98
+ this.#whenTrue = options.whenTrue;
99
+ this.#whenFalse = options.whenFalse ?? null;
100
+ }
101
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
102
+ reflect(host, isTrue) {
103
+ const shown = this.#inside(host, this.#whenTrue());
104
+ if (!this.#whenFalse) {
105
+ for (const region of shown) this.#write(region, !isTrue);
106
+ return;
107
+ }
108
+ const hiddenSide = this.#inside(host, this.#whenFalse());
109
+ if (shown.length === 0 || hiddenSide.length === 0) {
110
+ for (const region of shown) this.#give(region);
111
+ for (const region of hiddenSide) this.#give(region);
112
+ return;
113
+ }
114
+ for (const region of shown) this.#write(region, !isTrue);
115
+ for (const region of hiddenSide) this.#write(region, isTrue);
116
+ }
117
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
118
+ #write(region, hidden) {
119
+ if (hidden) this.#taken.add(region);
120
+ else this.#taken.delete(region);
121
+ if (region.hidden !== hidden) region.hidden = hidden;
122
+ }
123
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
124
+ #give(region) {
125
+ if (this.#taken.delete(region)) region.hidden = false;
126
+ }
127
+ /** The declared regions that sit within `host`, which may be the host itself. */
128
+ #inside(host, regions) {
129
+ return regions.filter((region) => host.contains(region));
130
+ }
131
+ };
132
+
92
133
  // src/controllers/stick_to_bottom_controller.ts
93
134
  var DEFAULT_THRESHOLD = 80;
94
135
  var countElements = (nodes) => {
@@ -97,7 +138,7 @@ var countElements = (nodes) => {
97
138
  return n;
98
139
  };
99
140
  var StickToBottomController = class extends Controller {
100
- static targets = ["content"];
141
+ static targets = ["content", "hasNew"];
101
142
  static values = {
102
143
  threshold: { type: Number, default: DEFAULT_THRESHOLD },
103
144
  behavior: { type: String, default: "auto" },
@@ -113,11 +154,14 @@ var StickToBottomController = class extends Controller {
113
154
  #awaitingLayout = false;
114
155
  #connected = false;
115
156
  #pinned = false;
157
+ #hasNew = false;
158
+ /** Owns `hidden` on the regions declared for the has-new state. */
159
+ #hasNewRegion = new StateRegions({ whenTrue: () => this.hasNewTargets });
116
160
  #onScroll = () => this.#updatePinned();
117
161
  connect() {
118
162
  this.#connected = true;
119
163
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
120
- this.element.removeAttribute("data-has-new");
164
+ this.#setHasNew(false);
121
165
  this.#pinned = this.#isPinned();
122
166
  this.#reflectPinned();
123
167
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
@@ -138,6 +182,19 @@ var StickToBottomController = class extends Controller {
138
182
  contentTargetDisconnected() {
139
183
  this.#syncWatched();
140
184
  }
185
+ /**
186
+ * Settles a has-new region that arrived at runtime on the side the flag is currently
187
+ * on, the way a region present at connect is settled by the connect-time normalization.
188
+ *
189
+ * Stimulus attaches targets before `connect()`, and a container that leaves the document
190
+ * and comes back keeps its controller, so in that window the flag still reads the
191
+ * arrival of the connection that ended. A region attached there is left to the
192
+ * connect-time normalization, which drops the flag and settles the region in one write.
193
+ */
194
+ hasNewTargetConnected() {
195
+ if (!this.#connected) return;
196
+ this.#hasNewRegion.reflect(this.element, this.#hasNew);
197
+ }
141
198
  /**
142
199
  * Re-derives pinned when the distance that counts as the bottom is changed at runtime
143
200
  * (a morph that swaps the attribute on a retained element).
@@ -159,7 +216,7 @@ var StickToBottomController = class extends Controller {
159
216
  */
160
217
  scrollToBottom() {
161
218
  this.#scrollToBottom();
162
- this.element.removeAttribute("data-has-new");
219
+ this.#setHasNew(false);
163
220
  this.#updatePinned();
164
221
  }
165
222
  /** Follows appended children while pinned; otherwise flags new content. */
@@ -170,7 +227,7 @@ var StickToBottomController = class extends Controller {
170
227
  if (this.#pinned) {
171
228
  this.#scrollToBottom();
172
229
  } else {
173
- this.element.setAttribute("data-has-new", "true");
230
+ this.#setHasNew(true);
174
231
  this.dispatch("new", { detail: { count: added } });
175
232
  }
176
233
  }
@@ -190,11 +247,24 @@ var StickToBottomController = class extends Controller {
190
247
  #reflectPinned() {
191
248
  if (this.#pinned) {
192
249
  this.element.setAttribute("data-pinned", "true");
193
- this.element.removeAttribute("data-has-new");
250
+ this.#setHasNew(false);
194
251
  } else {
195
252
  this.element.removeAttribute("data-pinned");
196
253
  }
197
254
  }
255
+ /**
256
+ * Mirrors whether content has arrived that the container did not follow onto the state
257
+ * hook and onto the regions declared for that state.
258
+ */
259
+ #setHasNew(hasNew) {
260
+ this.#hasNew = hasNew;
261
+ if (hasNew) {
262
+ this.element.setAttribute("data-has-new", "true");
263
+ } else {
264
+ this.element.removeAttribute("data-has-new");
265
+ }
266
+ this.#hasNewRegion.reflect(this.element, hasNew);
267
+ }
198
268
  /** Whether the container currently sits within `threshold` of its bottom. */
199
269
  #isPinned() {
200
270
  if (!this.#measurable()) return false;
@@ -331,7 +331,12 @@ var SubmitOnceController = class extends Controller {
331
331
  if (!session || !this.#isNativeSubmitControl(target)) return;
332
332
  this.#enterControl(session, target);
333
333
  }
334
- /** Ends one session, restores owned state, and reports the observable completion. */
334
+ /**
335
+ * Ends one session, restores owned state, and reports the observable completion.
336
+ *
337
+ * @stimeoRuntimeOnly `announceReadyText` words the one announcement of this finished submission
338
+ * and `restoreFocus` decides where its focus returns.
339
+ */
335
340
  #complete(form, reason, success) {
336
341
  const session = this.#sessionFor(form);
337
342
  if (!session) return;
@@ -353,7 +358,12 @@ var SubmitOnceController = class extends Controller {
353
358
  this.#disabled.write(control, "");
354
359
  this.#controlBusy.write(control, "true");
355
360
  }
356
- /** Swaps only the triggering control's safe label channel. */
361
+ /**
362
+ * Swaps only the triggering control's safe label channel.
363
+ *
364
+ * @stimeoRuntimeOnly `busyLabel` is the label this one submission shows until it ends; the idle
365
+ * label comes back with the state it returns.
366
+ */
357
367
  #enterLabel(session) {
358
368
  const control = session.submitter;
359
369
  if (!control) return;
@@ -440,7 +450,10 @@ var SubmitOnceController = class extends Controller {
440
450
  }
441
451
  this.#sessions.clear();
442
452
  }
443
- /** Keeps Turbo's cached snapshot idle, without events, announcements, or focus moves. */
453
+ /**
454
+ * Keeps Turbo's cached snapshot idle without `end`, announcements, or focus
455
+ * moves; the abandoned forms are reported once as `reconcile`.
456
+ */
444
457
  #rewindForCache() {
445
458
  const forms = [...this.#sessions.keys()];
446
459
  this.#abandonSessions();
@@ -9,6 +9,16 @@ function setDefaultAttribute(element, name, value) {
9
9
  return true;
10
10
  }
11
11
 
12
+ // src/utils/field_mirror.ts
13
+ function writeField(field, value) {
14
+ if (field.value === value) return false;
15
+ field.value = value;
16
+ return true;
17
+ }
18
+ function commitField(target) {
19
+ target.dispatchEvent(new Event("change", { bubbles: true }));
20
+ }
21
+
12
22
  // src/utils/focus_candidate.ts
13
23
  function inheritsFieldsetDisabled(control) {
14
24
  let fieldset = control.closest("fieldset[disabled]");
@@ -37,6 +47,39 @@ function isInteractiveHost(element) {
37
47
  return false;
38
48
  }
39
49
 
50
+ // src/utils/microtask_coalescer.ts
51
+ var MicrotaskCoalescer = class {
52
+ #run;
53
+ #queued = false;
54
+ #active = false;
55
+ #generation = 0;
56
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
57
+ constructor(run) {
58
+ this.#run = run;
59
+ }
60
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
61
+ activate() {
62
+ this.#active = true;
63
+ }
64
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
65
+ cancel() {
66
+ this.#active = false;
67
+ this.#queued = false;
68
+ this.#generation += 1;
69
+ }
70
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
71
+ schedule() {
72
+ if (!this.#active || this.#queued) return;
73
+ this.#queued = true;
74
+ const generation = this.#generation;
75
+ queueMicrotask(() => {
76
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
77
+ this.#queued = false;
78
+ this.#run();
79
+ });
80
+ }
81
+ };
82
+
40
83
  // src/controllers/switch_controller.ts
41
84
  var OBSERVED_ATTRIBUTES = [
42
85
  "role",
@@ -49,17 +92,20 @@ var OBSERVED_ATTRIBUTES = [
49
92
  ];
50
93
  var OBSERVED_ANCESTOR_ATTRIBUTES = ["contenteditable"];
51
94
  var SwitchController = class extends Controller {
95
+ static targets = ["field"];
52
96
  static actions = ["onKeydown", "toggle"];
53
- static events = ["changed"];
97
+ static events = ["change", "reconcile"];
54
98
  /** Defaults this instance introduced and may therefore remove safely. */
55
99
  #ownedDefaults = /* @__PURE__ */ new Set();
56
100
  /** Controller writes that must not be mistaken for authored morph changes. */
57
101
  #internalAttributeValues = /* @__PURE__ */ new Map();
102
+ /** One pass per batch of retained-element changes and field arrivals. */
103
+ #pass = new MicrotaskCoalescer(() => this.#reconcile());
104
+ /** The checked state last published: read on connect, then committed or reported. */
105
+ #committedChecked = false;
58
106
  #attributeObserver = new MutationObserver((records) => {
59
107
  this.#releaseAuthoredDefaults(records);
60
- this.#attributeObserver.disconnect();
61
- this.#reconcileDefaults();
62
- this.#observeAttributes();
108
+ this.#pass.schedule();
63
109
  });
64
110
  /** Blocks disabled pointer/native-key activation before consumer click handlers. */
65
111
  #onClickCapture = (event) => {
@@ -67,14 +113,25 @@ var SwitchController = class extends Controller {
67
113
  event.preventDefault();
68
114
  event.stopImmediatePropagation();
69
115
  };
70
- /** Ensures the switch exposes a role and is keyboard-reachable. */
116
+ /**
117
+ * Ensures the switch exposes a role and is keyboard-reachable, and takes the
118
+ * state it finds as the one a later move is measured from, reporting nothing.
119
+ */
71
120
  connect() {
72
121
  this.#reconcileDefaults();
122
+ this.#mirrorField(false);
123
+ this.#committedChecked = this.#checked;
73
124
  this.element.addEventListener("click", this.#onClickCapture, true);
125
+ this.#pass.activate();
74
126
  this.#observeAttributes();
75
127
  }
76
- /** Releases the explicit click guard and retained-element attribute observer. */
128
+ /** Fills a form field inserted or replaced at runtime with the current state. */
129
+ fieldTargetConnected() {
130
+ this.#pass.schedule();
131
+ }
132
+ /** Releases the click guard, the attribute observer, and a pass still queued. */
77
133
  disconnect() {
134
+ this.#pass.cancel();
78
135
  this.element.removeEventListener("click", this.#onClickCapture, true);
79
136
  this.#attributeObserver.disconnect();
80
137
  this.#internalAttributeValues.clear();
@@ -82,7 +139,7 @@ var SwitchController = class extends Controller {
82
139
  /** Toggles the checked state. Bound via `data-action` (click). */
83
140
  toggle() {
84
141
  if (!this.#isSupportedHost || this.#isActivationDisabled) return;
85
- this.#checked = !this.#checked;
142
+ this.#commit(!this.#checked);
86
143
  }
87
144
  /**
88
145
  * Activates the switch on Space/Enter for non-native hosts and suppresses key
@@ -107,6 +164,28 @@ var SwitchController = class extends Controller {
107
164
  if (event.repeat) return;
108
165
  this.toggle();
109
166
  }
167
+ /**
168
+ * Re-derives the defaults and the field after the page changed the host, then
169
+ * reports a checked state that moved. Records still queued are read first, so
170
+ * an author's write is not lost when observation is suspended for this pass's
171
+ * own writes; observation resumes before the report, so an edit a listener
172
+ * makes is seen by the next pass.
173
+ */
174
+ #reconcile() {
175
+ this.#releaseAuthoredDefaults(this.#attributeObserver.takeRecords());
176
+ this.#attributeObserver.disconnect();
177
+ this.#reconcileDefaults();
178
+ this.#mirrorField(false);
179
+ this.#observeAttributes();
180
+ this.#reportMove();
181
+ }
182
+ /** Reports a checked state that moved since the one this switch last published. */
183
+ #reportMove() {
184
+ const checked = this.#checked;
185
+ if (checked === this.#committedChecked) return;
186
+ this.#committedChecked = checked;
187
+ this.dispatch("reconcile", { detail: { checked } });
188
+ }
110
189
  /** Re-adds only missing defaults, preserving every authored attribute value. */
111
190
  #reconcileDefaults() {
112
191
  if (!this.#isSupportedHost) {
@@ -119,6 +198,12 @@ var SwitchController = class extends Controller {
119
198
  this.#setOwnedDefault("tabindex", "0");
120
199
  }
121
200
  }
201
+ /** Mirrors the checked state into the optional form field. */
202
+ #mirrorField(notify) {
203
+ if (!this.hasFieldTarget) return;
204
+ const moved = writeField(this.fieldTarget, this.#checked ? "true" : "false");
205
+ if (moved && notify) commitField(this.fieldTarget);
206
+ }
122
207
  /** Records a newly introduced default without claiming authored markup. */
123
208
  #setOwnedDefault(name, value) {
124
209
  if (setDefaultAttribute(this.element, name, value)) this.#ownedDefaults.add(name);
@@ -181,12 +266,18 @@ var SwitchController = class extends Controller {
181
266
  get #checked() {
182
267
  return this.element.getAttribute("aria-checked") === "true";
183
268
  }
184
- /** Reflects the new state on `aria-checked` and notifies listeners. */
185
- set #checked(value) {
269
+ /**
270
+ * Applies one toggle the user made. The state is taken as published before
271
+ * either report goes out, so a listener that toggles again or writes the
272
+ * attribute is measured from it.
273
+ */
274
+ #commit(value) {
186
275
  const reflected = value ? "true" : "false";
187
276
  this.#internalAttributeValues.set("aria-checked", reflected);
188
277
  this.element.setAttribute("aria-checked", reflected);
189
- this.dispatch("changed", { detail: { checked: value } });
278
+ this.#committedChecked = value;
279
+ this.#mirrorField(true);
280
+ this.dispatch("change", { detail: { checked: value } });
190
281
  }
191
282
  };
192
283
 
@@ -17,6 +17,9 @@ function isReservedArrowChord(event, allow = []) {
17
17
  var TabsController = class extends Controller {
18
18
  static targets = ["tab", "panel", "list"];
19
19
  static actions = ["onKeydown", "select"];
20
+ static events = ["change"];
21
+ /** Whether selection moves are reported: set once `connect()` settled the baseline. */
22
+ #reporting = false;
20
23
  /**
21
24
  * Selects the initially active tab: the pre-selected one, else the first.
22
25
  *
@@ -30,6 +33,14 @@ var TabsController = class extends Controller {
30
33
  (tab) => tab.getAttribute("aria-selected") === "true"
31
34
  );
32
35
  this.#selectIndex(preselected === -1 ? 0 : preselected, { focus: false });
36
+ this.#reporting = true;
37
+ }
38
+ /**
39
+ * Stops reporting until the next `connect()` has settled its baseline, so that
40
+ * normalization stays silent.
41
+ */
42
+ disconnect() {
43
+ this.#reporting = false;
33
44
  }
34
45
  /** Selects the clicked tab. Bound via `data-action` (click). */
35
46
  select(event) {
@@ -69,7 +80,9 @@ var TabsController = class extends Controller {
69
80
  * `tabindex`, and panel visibility. Optionally moves focus to the new tab.
70
81
  */
71
82
  #selectIndex(index, { focus }) {
72
- this.tabTargets.forEach((tab, i) => {
83
+ const tabs = this.tabTargets;
84
+ const previous = tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true");
85
+ tabs.forEach((tab, i) => {
73
86
  const selected = i === index;
74
87
  tab.setAttribute("aria-selected", selected ? "true" : "false");
75
88
  tab.tabIndex = selected ? 0 : -1;
@@ -77,7 +90,13 @@ var TabsController = class extends Controller {
77
90
  this.panelTargets.forEach((panel, i) => {
78
91
  panel.hidden = i !== index;
79
92
  });
80
- if (focus) this.tabTargets[index]?.focus();
93
+ if (previous !== index && this.#reporting) {
94
+ this.dispatch("change", {
95
+ detail: { index, total: tabs.length, previous },
96
+ cancelable: false
97
+ });
98
+ }
99
+ if (focus && tabs[index]?.getAttribute("aria-selected") === "true") tabs[index]?.focus();
81
100
  }
82
101
  };
83
102