stimeo-ui 0.5.0 → 0.6.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 (96) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/controllers/aspect_ratio_controller.d.ts +1 -3
  3. package/dist/controllers/aspect_ratio_controller.js +19 -11
  4. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  5. package/dist/controllers/avatar_controller.d.ts +36 -15
  6. package/dist/controllers/avatar_controller.js +195 -40
  7. package/dist/controllers/avatar_controller.js.map +1 -1
  8. package/dist/controllers/carousel_controller.d.ts +12 -3
  9. package/dist/controllers/carousel_controller.js +85 -9
  10. package/dist/controllers/carousel_controller.js.map +1 -1
  11. package/dist/controllers/checkbox_controller.d.ts +31 -5
  12. package/dist/controllers/checkbox_controller.js +136 -25
  13. package/dist/controllers/checkbox_controller.js.map +1 -1
  14. package/dist/controllers/color_picker_controller.d.ts +7 -1
  15. package/dist/controllers/color_picker_controller.js +35 -9
  16. package/dist/controllers/color_picker_controller.js.map +1 -1
  17. package/dist/controllers/date_range_picker_controller.d.ts +23 -4
  18. package/dist/controllers/date_range_picker_controller.js +157 -30
  19. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  20. package/dist/controllers/file_dropzone_controller.d.ts +7 -1
  21. package/dist/controllers/file_dropzone_controller.js +26 -3
  22. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  23. package/dist/controllers/focus_controller.d.ts +2 -3
  24. package/dist/controllers/focus_controller.js.map +1 -1
  25. package/dist/controllers/hover_card_controller.d.ts +2 -2
  26. package/dist/controllers/hover_card_controller.js.map +1 -1
  27. package/dist/controllers/idle_controller.d.ts +3 -3
  28. package/dist/controllers/idle_controller.js +27 -5
  29. package/dist/controllers/idle_controller.js.map +1 -1
  30. package/dist/controllers/menubar_controller.js +5 -3
  31. package/dist/controllers/menubar_controller.js.map +1 -1
  32. package/dist/controllers/multi_select_controller.d.ts +46 -12
  33. package/dist/controllers/multi_select_controller.js +460 -151
  34. package/dist/controllers/multi_select_controller.js.map +1 -1
  35. package/dist/controllers/number_input_controller.d.ts +24 -6
  36. package/dist/controllers/number_input_controller.js +275 -51
  37. package/dist/controllers/number_input_controller.js.map +1 -1
  38. package/dist/controllers/overflow_menu_controller.js +4 -0
  39. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  40. package/dist/controllers/pagination_controller.js +33 -0
  41. package/dist/controllers/pagination_controller.js.map +1 -1
  42. package/dist/controllers/password_strength_controller.d.ts +3 -3
  43. package/dist/controllers/password_strength_controller.js +20 -2
  44. package/dist/controllers/password_strength_controller.js.map +1 -1
  45. package/dist/controllers/persist_controller.d.ts +4 -4
  46. package/dist/controllers/persist_controller.js +24 -5
  47. package/dist/controllers/persist_controller.js.map +1 -1
  48. package/dist/controllers/popover_controller.d.ts +2 -2
  49. package/dist/controllers/popover_controller.js.map +1 -1
  50. package/dist/controllers/portal_controller.d.ts +1 -2
  51. package/dist/controllers/portal_controller.js.map +1 -1
  52. package/dist/controllers/radio_group_controller.d.ts +41 -15
  53. package/dist/controllers/radio_group_controller.js +540 -56
  54. package/dist/controllers/radio_group_controller.js.map +1 -1
  55. package/dist/controllers/rating_controller.d.ts +36 -31
  56. package/dist/controllers/rating_controller.js +272 -89
  57. package/dist/controllers/rating_controller.js.map +1 -1
  58. package/dist/controllers/resizable_controller.js +33 -0
  59. package/dist/controllers/resizable_controller.js.map +1 -1
  60. package/dist/controllers/roving_controller.d.ts +4 -0
  61. package/dist/controllers/roving_controller.js +60 -5
  62. package/dist/controllers/roving_controller.js.map +1 -1
  63. package/dist/controllers/scroll_area_controller.d.ts +6 -0
  64. package/dist/controllers/scroll_area_controller.js +154 -22
  65. package/dist/controllers/scroll_area_controller.js.map +1 -1
  66. package/dist/controllers/scroll_visibility_controller.js +33 -0
  67. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  68. package/dist/controllers/tags_input_controller.d.ts +41 -11
  69. package/dist/controllers/tags_input_controller.js +356 -120
  70. package/dist/controllers/tags_input_controller.js.map +1 -1
  71. package/dist/controllers/time_picker_controller.d.ts +42 -13
  72. package/dist/controllers/time_picker_controller.js +296 -107
  73. package/dist/controllers/time_picker_controller.js.map +1 -1
  74. package/dist/controllers/toggle_group_controller.d.ts +39 -13
  75. package/dist/controllers/toggle_group_controller.js +378 -55
  76. package/dist/controllers/toggle_group_controller.js.map +1 -1
  77. package/dist/controllers/toolbar_controller.js +5 -3
  78. package/dist/controllers/toolbar_controller.js.map +1 -1
  79. package/dist/controllers/tooltip_controller.d.ts +2 -2
  80. package/dist/controllers/tooltip_controller.js.map +1 -1
  81. package/dist/controllers/transition_controller.d.ts +2 -3
  82. package/dist/controllers/transition_controller.js.map +1 -1
  83. package/dist/controllers/tree_view_controller.js +5 -3
  84. package/dist/controllers/tree_view_controller.js.map +1 -1
  85. package/dist/index.js +2637 -781
  86. package/dist/index.js.map +1 -1
  87. package/dist/inspector/cli.d.ts +16 -3
  88. package/dist/inspector/cli.js +22 -6
  89. package/dist/inspector/cli.js.map +1 -1
  90. package/dist/inspector/cli_bin.js +22 -6
  91. package/dist/inspector/cli_bin.js.map +1 -1
  92. package/dist/inspector/examples.json +8 -8
  93. package/dist/inspector/manifest.json +169 -37
  94. package/dist/positioning/index.d.ts +2 -2
  95. package/dist/positioning/index.js.map +1 -1
  96. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -850,33 +850,41 @@ function visuallyHide(node) {
850
850
  style.clipPath = "inset(50%)";
851
851
  style.whiteSpace = "nowrap";
852
852
  }
853
+ var DEFAULT_RATIO = "1 / 1";
854
+ var RATIO_PROPERTY = "--stimeo--aspect-ratio";
855
+ var CSS_NUMBER_PATTERN = /^[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?$/;
853
856
  var AspectRatioController = class extends Controller {
854
- static targets = ["content"];
855
857
  static values = {
856
858
  ratio: { type: String, default: "1/1" }
857
859
  };
858
860
  /** Applies the ratio on connect and whenever the value changes. */
859
861
  ratioValueChanged() {
860
- this.element.style.setProperty("--stimeo--aspect-ratio", this.#normalizeRatio(this.ratioValue));
862
+ this.element.style.setProperty(RATIO_PROPERTY, this.#normalizeRatio(this.ratioValue));
861
863
  }
862
864
  /**
863
865
  * Normalizes a ratio string to a valid CSS `<ratio>`:
864
- * - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive numbers)
865
- * - `"1.5"` → `"1.5"` (a bare positive number)
866
+ * - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive CSS numbers)
867
+ * - `"1.5"` → `"1.5"` (a bare positive CSS number)
866
868
  * - anything else → `"1 / 1"` (the default), so the custom property is always valid.
867
869
  */
868
870
  #normalizeRatio(raw) {
869
871
  const value = raw.trim();
870
872
  if (value.includes("/")) {
871
- const [w, h] = value.split("/").map((part) => Number.parseFloat(part.trim()));
872
- if (this.#isPositive(w) && this.#isPositive(h)) return `${w} / ${h}`;
873
- return "1 / 1";
874
- }
875
- const single = Number.parseFloat(value);
876
- return this.#isPositive(single) ? String(single) : "1 / 1";
877
- }
878
- #isPositive(value) {
879
- return value !== void 0 && Number.isFinite(value) && value > 0;
873
+ const parts = value.split("/");
874
+ if (parts.length !== 2) return DEFAULT_RATIO;
875
+ const width = this.#parsePositiveNumber(parts[0] ?? "");
876
+ const height = this.#parsePositiveNumber(parts[1] ?? "");
877
+ return width !== void 0 && height !== void 0 ? `${width} / ${height}` : DEFAULT_RATIO;
878
+ }
879
+ const single = this.#parsePositiveNumber(value);
880
+ return single === void 0 ? DEFAULT_RATIO : String(single);
881
+ }
882
+ /** Parses one complete CSS `<number>` token and rejects units or trailing syntax. */
883
+ #parsePositiveNumber(raw) {
884
+ const token = raw.trim();
885
+ if (!CSS_NUMBER_PATTERN.test(token)) return void 0;
886
+ const value = Number(token);
887
+ return Number.isFinite(value) && value > 0 ? value : void 0;
880
888
  }
881
889
  };
882
890
 
@@ -1001,63 +1009,185 @@ var AutoSubmitController = class extends Controller {
1001
1009
  return this.onValue.split(/\s+/).filter(Boolean).includes(type);
1002
1010
  }
1003
1011
  };
1012
+
1013
+ // src/utils/attribute_lease.ts
1014
+ var AttributeLease = class {
1015
+ #attribute;
1016
+ #records = /* @__PURE__ */ new Map();
1017
+ /** @param attribute - The attribute whose temporary values this lease owns. */
1018
+ constructor(attribute) {
1019
+ this.#attribute = attribute;
1020
+ }
1021
+ /** Writes or removes the leased attribute while preserving its authored value. */
1022
+ write(element, value) {
1023
+ const existing = this.#records.get(element);
1024
+ if (existing) {
1025
+ existing.written = value;
1026
+ } else {
1027
+ this.#records.set(element, {
1028
+ original: element.getAttribute(this.#attribute),
1029
+ written: value
1030
+ });
1031
+ }
1032
+ if (value === null) element.removeAttribute(this.#attribute);
1033
+ else element.setAttribute(this.#attribute, value);
1034
+ }
1035
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
1036
+ return(element) {
1037
+ const record = this.#records.get(element);
1038
+ if (!record) return;
1039
+ this.#records.delete(element);
1040
+ if (element.getAttribute(this.#attribute) !== record.written) return;
1041
+ if (record.original === null) element.removeAttribute(this.#attribute);
1042
+ else element.setAttribute(this.#attribute, record.original);
1043
+ }
1044
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
1045
+ returnAll() {
1046
+ for (const element of Array.from(this.#records.keys())) this.return(element);
1047
+ }
1048
+ };
1049
+
1050
+ // src/controllers/avatar_controller.ts
1004
1051
  var AvatarController = class extends Controller {
1005
1052
  static targets = ["image", "fallback"];
1006
- static values = {
1007
- src: { type: String, default: "" }
1008
- };
1053
+ static values = { src: String };
1009
1054
  static actions = ["onError", "onLoad"];
1010
1055
  static events = ["error"];
1056
+ /** Collapses one morph's Value, target, and direct-attribute signals into one pass. */
1057
+ #reconcile = new MicrotaskCoalescer(() => this.#render());
1058
+ /** Preserves directly-authored source and visibility while targets are controlled. */
1059
+ #src = new AttributeLease("src");
1060
+ #imageHidden = new AttributeLease("hidden");
1061
+ #fallbackHidden = new AttributeLease("hidden");
1062
+ /** True only while target departure means ownership was actually relinquished. */
1063
+ #connected = false;
1064
+ /** Watches the non-Value render input used by the direct `<img src>` form. */
1065
+ #srcObserver = new MutationObserver((records) => {
1066
+ if (records.some((record) => this.hasImageTarget && record.target === this.imageTarget)) {
1067
+ this.#reconcile.schedule();
1068
+ }
1069
+ });
1070
+ /** Starts retained-DOM observation and silently derives the current phase. */
1011
1071
  connect() {
1012
- if (!this.hasImageTarget) {
1013
- this.#showFallback();
1014
- return;
1072
+ this.#connected = true;
1073
+ this.#reconcile.activate();
1074
+ this.#srcObserver.observe(this.element, {
1075
+ attributes: true,
1076
+ attributeFilter: ["src"],
1077
+ subtree: true
1078
+ });
1079
+ this.#render();
1080
+ }
1081
+ /** Cancels asynchronous work while retaining the last materialized visual state. */
1082
+ disconnect() {
1083
+ this.#connected = false;
1084
+ this.#reconcile.cancel();
1085
+ this.#srcObserver.disconnect();
1086
+ }
1087
+ /** Repaints when application code or a Turbo morph changes the source Value. */
1088
+ srcValueChanged() {
1089
+ this.#reconcile.schedule();
1090
+ }
1091
+ /** Applies the current source and phase to an image inserted or replaced at runtime. */
1092
+ imageTargetConnected() {
1093
+ this.#reconcile.schedule();
1094
+ }
1095
+ /** Returns attributes borrowed from an image that leaves this live avatar. */
1096
+ imageTargetDisconnected(image) {
1097
+ if (this.#connected) {
1098
+ this.#src.return(image);
1099
+ this.#imageHidden.return(image);
1015
1100
  }
1016
- if (this.srcValue) {
1017
- this.imageTarget.src = this.srcValue;
1101
+ this.#reconcile.schedule();
1102
+ }
1103
+ /** Applies the current phase to a fallback inserted or replaced at runtime. */
1104
+ fallbackTargetConnected() {
1105
+ this.#reconcile.schedule();
1106
+ }
1107
+ /** Returns authored visibility when a fallback leaves this live avatar. */
1108
+ fallbackTargetDisconnected(fallback) {
1109
+ if (this.#connected) this.#fallbackHidden.return(fallback);
1110
+ this.#reconcile.schedule();
1111
+ }
1112
+ /** Reveals the current image after its native `load` event. */
1113
+ onLoad(event) {
1114
+ const image = this.#eventImage(event);
1115
+ if (!image) return;
1116
+ if (!image.getAttribute("src")) {
1117
+ this.#render();
1118
+ return;
1018
1119
  }
1019
- const src = this.imageTarget.getAttribute("src");
1120
+ this.#reflect("loaded");
1121
+ }
1122
+ /**
1123
+ * Reveals the fallback after the current image's native `error` event.
1124
+ *
1125
+ * Dispatches `stimeo--avatar:error` with `{ src: string }`, where `src` is
1126
+ * the attempted attribute value. Empty/stale/detached image events are ignored.
1127
+ */
1128
+ onError(event) {
1129
+ const image = this.#eventImage(event);
1130
+ if (!image) return;
1131
+ const src = image.getAttribute("src");
1020
1132
  if (!src) {
1021
- this.#showFallback();
1133
+ this.#render();
1134
+ return;
1135
+ }
1136
+ this.#reflect("error");
1137
+ this.dispatch("error", { detail: { src } });
1138
+ }
1139
+ /**
1140
+ * Materializes the phase from the final source/target DOM after one mutation batch.
1141
+ *
1142
+ * Cached success and failure are state reconstruction, not new load events, so
1143
+ * this pass never dispatches the public `error` event.
1144
+ *
1145
+ * @stimeoRenderRoot
1146
+ */
1147
+ #render() {
1148
+ if (!this.hasImageTarget) {
1149
+ this.#reflect("empty");
1022
1150
  return;
1023
1151
  }
1024
- if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {
1025
- this.#showImage();
1152
+ const image = this.imageTarget;
1153
+ const src = this.#applySource(image);
1154
+ if (!src) {
1155
+ this.#reflect("empty");
1026
1156
  return;
1027
1157
  }
1028
- if (this.imageTarget.complete && this.imageTarget.naturalWidth === 0 && src) {
1029
- this.onError();
1158
+ if (!image.complete) {
1159
+ this.#reflect("loading");
1030
1160
  return;
1031
1161
  }
1032
- this.#enterLoading();
1162
+ this.#reflect(image.naturalWidth > 0 ? "loaded" : "error");
1033
1163
  }
1034
- /** Reveals the image once it has loaded successfully. */
1035
- onLoad() {
1036
- this.#showImage();
1037
- }
1038
- /** Swaps to the fallback when the image fails and emits `error`. */
1039
- onError() {
1040
- const src = this.hasImageTarget ? this.imageTarget.getAttribute("src") ?? "" : "";
1041
- this.#showFallback();
1042
- this.dispatch("error", { detail: { src } });
1043
- }
1044
- /** Loading phase: keep the image visible (per markup) while it fetches. */
1045
- #enterLoading() {
1046
- if (this.hasImageTarget) this.imageTarget.hidden = false;
1047
- if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;
1048
- this.element.setAttribute("data-state", "loading");
1164
+ /** Applies Value precedence and returns the effective raw `src` attribute. */
1165
+ #applySource(image) {
1166
+ if (!this.element.hasAttribute("data-stimeo--avatar-src-value")) {
1167
+ this.#src.return(image);
1168
+ return image.getAttribute("src");
1169
+ }
1170
+ const desired = this.srcValue.length > 0 ? this.srcValue : null;
1171
+ if (image.getAttribute("src") !== desired) this.#src.write(image, desired);
1172
+ return desired;
1049
1173
  }
1050
- /** Loaded phase: image visible, fallback hidden. */
1051
- #showImage() {
1052
- if (this.hasImageTarget) this.imageTarget.hidden = false;
1053
- if (this.hasFallbackTarget) this.fallbackTarget.hidden = true;
1054
- this.element.setAttribute("data-state", "loaded");
1174
+ /** Accepts an action only from the singular image target currently controlled. */
1175
+ #eventImage(event) {
1176
+ const candidate = event.currentTarget;
1177
+ if (!(candidate instanceof HTMLImageElement)) return null;
1178
+ if (!this.hasImageTarget || candidate !== this.imageTarget) return null;
1179
+ return candidate;
1055
1180
  }
1056
- /** Error / no-src phase: fallback visible, image hidden. */
1057
- #showFallback() {
1058
- if (this.hasImageTarget) this.imageTarget.hidden = true;
1059
- if (this.hasFallbackTarget) this.fallbackTarget.hidden = false;
1060
- this.element.setAttribute("data-state", "error");
1181
+ /** Writes the visibility pair and public root state as one controller-owned output. */
1182
+ #reflect(state) {
1183
+ const showImage = state === "loading" || state === "loaded";
1184
+ if (this.hasImageTarget) {
1185
+ this.#imageHidden.write(this.imageTarget, showImage ? null : "");
1186
+ }
1187
+ if (this.hasFallbackTarget) {
1188
+ this.#fallbackHidden.write(this.fallbackTarget, showImage ? "" : null);
1189
+ }
1190
+ this.element.setAttribute("data-state", state);
1061
1191
  }
1062
1192
  };
1063
1193
 
@@ -1897,10 +2027,12 @@ var RovingTabindex = class {
1897
2027
  * "nothing is currently tabbable".
1898
2028
  *
1899
2029
  * @param index - Position of the item to make tabbable.
1900
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
2030
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
2031
+ * and `items` to reuse an event-scoped collection snapshot.
1901
2032
  */
1902
- setActive(index, { focus = false } = {}) {
1903
- const items = this.#getItems();
2033
+ setActive(index, options = {}) {
2034
+ const { focus = false } = options;
2035
+ const items = options.items ?? this.#getItems();
1904
2036
  items.forEach((item, i) => {
1905
2037
  item.tabIndex = i === index ? 0 : -1;
1906
2038
  });
@@ -1931,9 +2063,14 @@ var CarouselController = class extends Controller {
1931
2063
  "resume",
1932
2064
  "togglePlay"
1933
2065
  ];
1934
- static events = ["change", "pause", "play"];
2066
+ static events = ["change", "pause", "play", "reconcile"];
1935
2067
  #roving = new RovingTabindex(() => this.pickerTargets);
1936
- /** Gates the target callback so it does not repaint once per authored picker on mount. */
2068
+ #reconcileTargets = new MicrotaskCoalescer(() => this.#reconcileTargetSet());
2069
+ /**
2070
+ * Whether `connect()` has run. Scheduling is already inert outside that window
2071
+ * ({@link MicrotaskCoalescer}), so this only gates the Tab stop a picker
2072
+ * present at mount authored for itself.
2073
+ */
1937
2074
  #connected = false;
1938
2075
  #intervals = new SafeInterval();
1939
2076
  /** Index of the visible slide. */
@@ -1959,6 +2096,7 @@ var CarouselController = class extends Controller {
1959
2096
  this.#render({ focus: false });
1960
2097
  this.#syncTimer();
1961
2098
  this.#connected = true;
2099
+ this.#reconcileTargets.activate();
1962
2100
  }
1963
2101
  /**
1964
2102
  * Re-establishes the single selected picker when one is added after connect.
@@ -1969,9 +2107,22 @@ var CarouselController = class extends Controller {
1969
2107
  * repaint re-derives every picker from `#index`, so a late arrival never steals
1970
2108
  * the selection.
1971
2109
  */
1972
- pickerTargetConnected() {
2110
+ pickerTargetConnected(picker) {
1973
2111
  if (!this.#connected) return;
1974
- this.#render({ focus: false });
2112
+ picker.tabIndex = -1;
2113
+ this.#reconcileTargets.schedule();
2114
+ }
2115
+ /** Repairs selection and roving after a picker leaves a retained carousel. */
2116
+ pickerTargetDisconnected() {
2117
+ this.#reconcileTargets.schedule();
2118
+ }
2119
+ /** Reconciles a slide added in the same DOM batch as its picker. */
2120
+ slideTargetConnected() {
2121
+ this.#reconcileTargets.schedule();
2122
+ }
2123
+ /** Re-clamps the active index after a slide is removed. */
2124
+ slideTargetDisconnected() {
2125
+ this.#reconcileTargets.schedule();
1975
2126
  }
1976
2127
  /**
1977
2128
  * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
@@ -1989,6 +2140,7 @@ var CarouselController = class extends Controller {
1989
2140
  /** Clears the autoplay interval so it never fires after teardown. */
1990
2141
  disconnect() {
1991
2142
  this.#connected = false;
2143
+ this.#reconcileTargets.cancel();
1992
2144
  this.#intervals.clearAll();
1993
2145
  this.#timerId = null;
1994
2146
  }
@@ -2093,10 +2245,31 @@ var CarouselController = class extends Controller {
2093
2245
  slide.setAttribute("data-state", active ? "active" : "inactive");
2094
2246
  slide.hidden = !active;
2095
2247
  });
2248
+ const pickerIndex = Math.min(this.#index, this.pickerTargets.length - 1);
2096
2249
  this.pickerTargets.forEach((picker, i) => {
2097
- picker.setAttribute("aria-selected", i === this.#index ? "true" : "false");
2250
+ picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
2098
2251
  });
2099
- this.#roving.setActive(this.#index, { focus });
2252
+ this.#roving.setActive(pickerIndex, { focus });
2253
+ }
2254
+ /** Keeps the live active slide when possible and otherwise selects the nearest survivor. */
2255
+ #reconcileTargetSet() {
2256
+ const activeSlide = this.slideTargets.findIndex(
2257
+ (slide) => slide.getAttribute("data-state") === "active"
2258
+ );
2259
+ const selectedPicker = this.pickerTargets.findIndex(
2260
+ (picker) => picker.getAttribute("aria-selected") === "true"
2261
+ );
2262
+ const lastSlide = this.slideTargets.length - 1;
2263
+ const candidate = activeSlide !== -1 ? activeSlide : selectedPicker !== -1 ? selectedPicker : this.#index;
2264
+ const previous = this.#index;
2265
+ this.#index = lastSlide < 0 ? 0 : Math.min(lastSlide, Math.max(0, candidate));
2266
+ this.#render({ focus: false });
2267
+ this.#syncTimer();
2268
+ if (this.#index !== previous) {
2269
+ this.dispatch("reconcile", {
2270
+ detail: { index: this.#index, total: this.slideTargets.length }
2271
+ });
2272
+ }
2100
2273
  }
2101
2274
  /**
2102
2275
  * Drives the autoplay interval toward the desired state. Autoplay should run
@@ -2232,14 +2405,51 @@ var CharacterCounterController = class _CharacterCounterController extends Contr
2232
2405
  var CheckboxController = class extends Controller {
2233
2406
  static targets = ["parent", "child"];
2234
2407
  static actions = ["onChildChange", "onParentChange"];
2235
- static events = ["change"];
2236
- /** Reflects the initial aggregate (e.g. from server-rendered child states). */
2237
- connect() {
2238
- if (this.childTargets.length > 0) {
2239
- this.#syncFromChildren(false);
2240
- } else {
2241
- this.element.setAttribute("data-state", this.#aggregate());
2408
+ static events = ["change", "reconcile"];
2409
+ /** Collapses every lifecycle signal from one DOM update into one derived pass. */
2410
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
2411
+ /** Aggregate this root last settled on, so a derived repair is reported once. */
2412
+ #committedState = null;
2413
+ /** Watches authored checked-attribute changes on retained target elements. */
2414
+ #checkedObserver = new MutationObserver((records) => {
2415
+ if (records.some((record) => this.#isManagedCheckbox(record.target))) {
2416
+ this.#reconcile.schedule();
2242
2417
  }
2418
+ });
2419
+ /** Reflects the initial aggregate and starts retained-DOM reconciliation. */
2420
+ connect() {
2421
+ this.#reconcile.activate();
2422
+ this.#syncFromChildren();
2423
+ this.#checkedObserver.observe(this.element, {
2424
+ attributes: true,
2425
+ attributeFilter: ["checked"],
2426
+ subtree: true
2427
+ });
2428
+ this.element.addEventListener("turbo:morph-element", this.#onMorph);
2429
+ document.addEventListener("reset", this.#onReset, true);
2430
+ }
2431
+ /** Releases the observer, global reset listener, and every pending reconciliation. */
2432
+ disconnect() {
2433
+ this.#reconcile.cancel();
2434
+ this.#checkedObserver.disconnect();
2435
+ this.element.removeEventListener("turbo:morph-element", this.#onMorph);
2436
+ document.removeEventListener("reset", this.#onReset, true);
2437
+ }
2438
+ /** Reconciles the aggregate for a parent added or replaced at runtime. */
2439
+ parentTargetConnected() {
2440
+ this.#reconcile.schedule();
2441
+ }
2442
+ /** Reconciles the aggregate after a parent target leaves the group. */
2443
+ parentTargetDisconnected() {
2444
+ this.#reconcile.schedule();
2445
+ }
2446
+ /** Reconciles the aggregate for a child added at runtime. */
2447
+ childTargetConnected() {
2448
+ this.#reconcile.schedule();
2449
+ }
2450
+ /** Reconciles the aggregate after a child leaves the group. */
2451
+ childTargetDisconnected() {
2452
+ this.#reconcile.schedule();
2243
2453
  }
2244
2454
  /** Cascades the parent's state to every child. Bound via `data-action` (change). */
2245
2455
  onParentChange() {
@@ -2248,35 +2458,74 @@ var CheckboxController = class extends Controller {
2248
2458
  for (const child of this.childTargets) {
2249
2459
  child.checked = checked;
2250
2460
  }
2251
- this.parentTarget.indeterminate = false;
2252
- const state = checked ? "all" : "none";
2253
- this.element.setAttribute("data-state", state);
2254
- this.dispatch("change", { detail: { checked, indeterminate: false, state } });
2461
+ this.#reflect(checked ? "all" : "none", true);
2462
+ const detail = this.#settledDetail();
2463
+ if (detail) this.dispatch("change", { detail });
2255
2464
  }
2256
2465
  /** Recomputes the parent from its children. Bound via `data-action` (change). */
2257
2466
  onChildChange() {
2258
- this.#syncFromChildren(true);
2467
+ this.#syncFromChildren();
2468
+ const detail = this.#settledDetail();
2469
+ if (detail) this.dispatch("change", { detail });
2470
+ }
2471
+ /**
2472
+ * Announces an aggregate this pass derived rather than the user. `change` stays
2473
+ * reserved for the two public actions, so automation never reads a repair as an edit.
2474
+ */
2475
+ #reconcileFromChildren() {
2476
+ const previous = this.#committedState;
2477
+ this.#syncFromChildren();
2478
+ if (this.#committedState === previous) return;
2479
+ const detail = this.#settledDetail();
2480
+ if (detail) this.dispatch("reconcile", { detail });
2259
2481
  }
2260
2482
  /**
2261
2483
  * Derives the parent's `checked`/`indeterminate` and the root `data-state` from
2262
- * the children, optionally dispatching `change`.
2484
+ * the children. Writing state and reporting it are separate so the caller — not
2485
+ * a flag threaded through the write — decides which event describes the cause.
2486
+ */
2487
+ #syncFromChildren() {
2488
+ this.#reflect(this.#aggregate(), this.childTargets.length > 0);
2489
+ }
2490
+ /**
2491
+ * Reflects one aggregate state.
2492
+ *
2493
+ * @param writeParent - whether the state is authoritative over the `parent`
2494
+ * target's own `checked` / `indeterminate`.
2263
2495
  */
2264
- #syncFromChildren(dispatch) {
2265
- const state = this.#aggregate();
2266
- if (this.hasParentTarget) {
2496
+ #reflect(state, writeParent) {
2497
+ if (writeParent && this.hasParentTarget) {
2267
2498
  this.parentTarget.checked = state === "all";
2268
2499
  this.parentTarget.indeterminate = state === "partial";
2269
2500
  }
2270
2501
  this.element.setAttribute("data-state", state);
2271
- if (dispatch) {
2272
- this.dispatch("change", {
2273
- detail: {
2274
- checked: this.hasParentTarget ? this.parentTarget.checked : state === "all",
2275
- indeterminate: this.hasParentTarget ? this.parentTarget.indeterminate : false,
2276
- state
2277
- }
2278
- });
2279
- }
2502
+ this.#committedState = state;
2503
+ }
2504
+ /** The settled aggregate as event detail, or `null` before anything has settled. */
2505
+ #settledDetail() {
2506
+ const state = this.#committedState;
2507
+ if (state === null) return null;
2508
+ return { checked: state === "all", indeterminate: state === "partial", state };
2509
+ }
2510
+ /** Reconciles retained targets after Turbo has finished morphing their live state. */
2511
+ #onMorph = () => {
2512
+ this.#reconcile.schedule();
2513
+ };
2514
+ /** Reconciles after a non-cancelled reset restores any managed checkbox. */
2515
+ #onReset = (event) => {
2516
+ const form = event.target;
2517
+ if (!(form instanceof HTMLFormElement) || !this.#hasCheckboxOwnedBy(form)) return;
2518
+ queueMicrotask(() => {
2519
+ if (!event.defaultPrevented) this.#reconcile.schedule();
2520
+ });
2521
+ };
2522
+ /** Whether a form owns at least one current parent or child target. */
2523
+ #hasCheckboxOwnedBy(form) {
2524
+ return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
2525
+ }
2526
+ /** Whether an observed attribute mutation belongs to this controller's target set. */
2527
+ #isManagedCheckbox(node) {
2528
+ return this.parentTargets.some((checkbox) => checkbox === node) || this.childTargets.some((checkbox) => checkbox === node);
2280
2529
  }
2281
2530
  /**
2282
2531
  * Computes the aggregate state. With children it counts them; with none it
@@ -2681,7 +2930,7 @@ var ColorPickerController = class extends Controller {
2681
2930
  logicalTrack: { type: Boolean, default: false }
2682
2931
  };
2683
2932
  static actions = ["onHexInput", "onKeydown", "onPointerDown"];
2684
- static events = ["change"];
2933
+ static events = ["change", "reconcile"];
2685
2934
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
2686
2935
  get #mirrored() {
2687
2936
  return this.logicalTrackValue && isRtl(this.element);
@@ -2690,14 +2939,14 @@ var ColorPickerController = class extends Controller {
2690
2939
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
2691
2940
  /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
2692
2941
  #dragAbort = null;
2693
- /** Seeds the model from the initial hex value and renders every surface. */
2942
+ /** Color the last repaint settled on, so a configuration-driven move is reported once. */
2943
+ #committedHex = null;
2694
2944
  /**
2695
2945
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
2696
2946
  * pass Stimulus delivers before `connect()`.
2697
2947
  */
2698
- #repaint = new MicrotaskCoalescer(() => {
2699
- this.#render();
2700
- });
2948
+ #repaint = new MicrotaskCoalescer(() => this.#reconcileColor());
2949
+ /** Seeds the model from the initial hex value and renders every surface. */
2701
2950
  connect() {
2702
2951
  this.#repaint.activate();
2703
2952
  const parsed = hexToHsla(this.valueValue);
@@ -2788,12 +3037,24 @@ var ColorPickerController = class extends Controller {
2788
3037
  return;
2789
3038
  }
2790
3039
  this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
2791
- this.#render();
3040
+ this.#commitColor();
2792
3041
  }
2793
3042
  /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
2794
3043
  #setChannel(channel, raw, min, max) {
2795
3044
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
3045
+ this.#commitColor();
3046
+ }
3047
+ /**
3048
+ * Renders the model and reports a color the user actually moved. A key pressed
3049
+ * at a bound, a pointer that lands on the step already showing, and a re-confirmed
3050
+ * hex all leave the committed color where it was, so no `change` describes them.
3051
+ */
3052
+ #commitColor() {
3053
+ const previous = this.#committedHex;
2796
3054
  this.#render();
3055
+ if (this.#committedHex !== previous) {
3056
+ this.dispatch("change", { detail: this.#settledDetail() });
3057
+ }
2797
3058
  }
2798
3059
  /**
2799
3060
  * Reflects the model onto sliders, the hex input, preview, and form field.
@@ -2809,14 +3070,28 @@ var ColorPickerController = class extends Controller {
2809
3070
  slider.setAttribute("aria-valuetext", valueText(channel, value));
2810
3071
  }
2811
3072
  const hex = this.#hexString();
3073
+ this.#committedHex = hex;
2812
3074
  if (this.hasHexTarget) this.hexTarget.value = hex;
2813
3075
  for (const field of this.fieldTargets) field.value = hex;
2814
3076
  for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
2815
3077
  this.element.style.setProperty(COLOR_PROPERTY, hex);
3078
+ }
3079
+ /**
3080
+ * Repaints after `alpha` changed at runtime and reports a color this controller
3081
+ * settled on. Disabling alpha drops it from the model, so the committed color can
3082
+ * move without a user edit; `change` stays reserved for the picker's own actions.
3083
+ */
3084
+ #reconcileColor() {
3085
+ const previous = this.#committedHex;
3086
+ this.#render();
3087
+ if (previous !== null && this.#committedHex !== previous) {
3088
+ this.dispatch("reconcile", { detail: this.#settledDetail() });
3089
+ }
3090
+ }
3091
+ /** The settled color as event detail, shared by both report paths. */
3092
+ #settledDetail() {
2816
3093
  const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
2817
- this.dispatch("change", {
2818
- detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } }
2819
- });
3094
+ return { value: this.#hexString(), rgba: { ...rgb, a: this.#color.alpha / 100 } };
2820
3095
  }
2821
3096
  /** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */
2822
3097
  #hexString() {
@@ -4663,15 +4938,35 @@ var DataGridController = class extends Controller {
4663
4938
  return cells[Math.min(col, cells.length - 1)];
4664
4939
  }
4665
4940
  };
4941
+
4942
+ // src/utils/string_list.ts
4943
+ function parseStringList(raw, fallback = []) {
4944
+ const text = raw.trim();
4945
+ if (text.length === 0) return [...fallback];
4946
+ let parsed;
4947
+ try {
4948
+ parsed = JSON.parse(text);
4949
+ } catch {
4950
+ return [...fallback];
4951
+ }
4952
+ if (!Array.isArray(parsed)) return [...fallback];
4953
+ return parsed.filter((entry) => typeof entry === "string");
4954
+ }
4955
+
4956
+ // src/controllers/date_range_picker_controller.ts
4666
4957
  var GRID_SIZE = 42;
4667
4958
  var DateRangePickerController = class extends Controller {
4668
4959
  static targets = ["grid", "monthLabel", "cell", "status", "startField", "endField"];
4669
4960
  static values = {
4670
4961
  min: { type: String, default: "" },
4671
- max: { type: String, default: "" }
4962
+ max: { type: String, default: "" },
4963
+ // A JSON list read through `parseStringList` rather than Stimulus's `Array`
4964
+ // type: that reader throws out of the value observer before any callback
4965
+ // runs, so one malformed attribute would stop the picker from connecting.
4966
+ disabledDates: { type: String, default: "" }
4672
4967
  };
4673
4968
  static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
4674
- static events = ["change"];
4969
+ static events = ["change", "monthchange"];
4675
4970
  /** The month currently rendered, as `YYYY-MM`. */
4676
4971
  #viewMonth = "";
4677
4972
  /** The confirmed range endpoints (ISO), or "" when unset. */
@@ -4685,7 +4980,12 @@ var DateRangePickerController = class extends Controller {
4685
4980
  #focusedDate = /* @__PURE__ */ new Date();
4686
4981
  /** Deferred focus after an async month transition (cancelled on teardown). */
4687
4982
  #focusTimer = new SafeTimeout();
4688
- /** Seeds the range from any pre-filled hidden fields and renders the grid. */
4983
+ /** The declared unavailable dates, indexed for the per-cell paint lookup. */
4984
+ #disabledDates = /* @__PURE__ */ new Set();
4985
+ /** Rewinds an unfinished selection before Turbo snapshots the page. */
4986
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
4987
+ /** Last painted month, or `null` until the initial paint has settled. */
4988
+ #announcedMonth = null;
4689
4989
  /**
4690
4990
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
4691
4991
  * pass Stimulus delivers before `connect()`.
@@ -4693,10 +4993,17 @@ var DateRangePickerController = class extends Controller {
4693
4993
  #repaint = new MicrotaskCoalescer(() => {
4694
4994
  this.#render();
4695
4995
  });
4996
+ /** Seeds and normalizes the range from optional hidden fields, then paints the grid. */
4696
4997
  connect() {
4697
4998
  this.#repaint.activate();
4698
- this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
4699
- this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
4999
+ this.#beforeCache.activate();
5000
+ const authoredStart = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
5001
+ const authoredEnd = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
5002
+ [this.#startDate, this.#endDate] = orderRange(authoredStart, authoredEnd);
5003
+ this.#pendingStart = "";
5004
+ this.#previewDate = "";
5005
+ this.#announcedMonth = null;
5006
+ this.#commitFields();
4700
5007
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
4701
5008
  this.#focusedDate = anchor;
4702
5009
  this.#viewMonth = toISOMonthString(anchor);
@@ -4706,6 +5013,7 @@ var DateRangePickerController = class extends Controller {
4706
5013
  /** Cancels any pending deferred focus so it never fires on a detached element. */
4707
5014
  disconnect() {
4708
5015
  this.#repaint.cancel();
5016
+ this.#beforeCache.deactivate();
4709
5017
  this.#focusTimer.clearAll();
4710
5018
  }
4711
5019
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
@@ -4716,6 +5024,11 @@ var DateRangePickerController = class extends Controller {
4716
5024
  maxValueChanged() {
4717
5025
  this.#repaint.schedule();
4718
5026
  }
5027
+ /** Re-indexes the unavailable dates and repaints when the declared set changes. */
5028
+ disabledDatesValueChanged() {
5029
+ this.#disabledDates = new Set(parseStringList(this.disabledDatesValue));
5030
+ this.#repaint.schedule();
5031
+ }
4719
5032
  /** Navigates to the previous month. */
4720
5033
  prev(event) {
4721
5034
  event?.preventDefault();
@@ -4731,7 +5044,7 @@ var DateRangePickerController = class extends Controller {
4731
5044
  const cell = this.#cellFrom(event.target);
4732
5045
  if (!cell) return;
4733
5046
  const date = cell.getAttribute("data-date");
4734
- if (!date || cell.getAttribute("aria-disabled") === "true") return;
5047
+ if (!date || !this.#isSelectable(date)) return;
4735
5048
  this.#choose(date);
4736
5049
  }
4737
5050
  /** Previews the range up to a hovered/focused cell while selecting. */
@@ -4740,28 +5053,27 @@ var DateRangePickerController = class extends Controller {
4740
5053
  if (!cell) return;
4741
5054
  const date = cell.getAttribute("data-date");
4742
5055
  if (!date) return;
5056
+ let shouldRender = false;
4743
5057
  if (event.type.startsWith("focus")) {
4744
5058
  const parsed = parseISODateString(date);
4745
5059
  if (parsed) this.#focusedDate = parsed;
4746
- if (!this.#pendingStart && cell.getAttribute("tabindex") !== "0") {
4747
- this.#render();
4748
- }
5060
+ shouldRender = cell.getAttribute("tabindex") !== "0";
4749
5061
  }
4750
- if (this.#pendingStart) {
5062
+ if (this.#pendingStart && this.#isSelectable(date) && this.#previewDate !== date) {
4751
5063
  this.#previewDate = date;
4752
- this.#render();
5064
+ shouldRender = true;
4753
5065
  }
5066
+ if (shouldRender) this.#render();
4754
5067
  }
4755
5068
  /** Applies a named preset (`today` / `last7` / `last30` / `thisMonth`). */
4756
5069
  applyPreset(event) {
4757
5070
  const button = event.target?.closest("[data-range]");
4758
- const name = button?.getAttribute("data-range");
4759
- if (!name) return;
4760
- const range = computePreset(name);
5071
+ const range = computePreset(button?.getAttribute("data-range") ?? "");
4761
5072
  if (!range) return;
4762
- const start = this.#clampISO(range.start);
4763
- const end = this.#clampISO(range.end);
4764
- if (!start || !end) return;
5073
+ const intersection = this.#intersectRange(range);
5074
+ if (!intersection) return;
5075
+ const { start, end } = intersection;
5076
+ if (!this.#isSelectable(start) || !this.#isSelectable(end)) return;
4765
5077
  this.#startDate = start;
4766
5078
  this.#endDate = end;
4767
5079
  this.#pendingStart = "";
@@ -4779,12 +5091,12 @@ var DateRangePickerController = class extends Controller {
4779
5091
  if (isReservedArrowChord(event)) return;
4780
5092
  const cell = this.#cellFrom(event.target);
4781
5093
  if (!cell) return;
4782
- const dateStr = cell.getAttribute("data-date");
4783
- const date = dateStr ? parseISODateString(dateStr) : null;
5094
+ const dateStr = cell.getAttribute("data-date") ?? "";
5095
+ const date = parseISODateString(dateStr);
4784
5096
  if (!date) return;
4785
5097
  if (event.key === "Enter" || event.key === " ") {
4786
5098
  event.preventDefault();
4787
- if (dateStr && cell.getAttribute("aria-disabled") !== "true") this.#choose(dateStr);
5099
+ if (this.#isSelectable(dateStr)) this.#choose(dateStr);
4788
5100
  return;
4789
5101
  }
4790
5102
  if (event.key === "Escape") {
@@ -4817,10 +5129,10 @@ var DateRangePickerController = class extends Controller {
4817
5129
  next = addDays(date, 6 - date.getDay());
4818
5130
  break;
4819
5131
  case "PageUp":
4820
- next = shiftMonthClamped(date, -1);
5132
+ next = event.shiftKey ? shiftYearClamped(date, -1) : shiftMonthClamped(date, -1);
4821
5133
  break;
4822
5134
  case "PageDown":
4823
- next = shiftMonthClamped(date, 1);
5135
+ next = event.shiftKey ? shiftYearClamped(date, 1) : shiftMonthClamped(date, 1);
4824
5136
  break;
4825
5137
  default:
4826
5138
  return;
@@ -4851,12 +5163,14 @@ var DateRangePickerController = class extends Controller {
4851
5163
  /** Moves roving focus to `date`, transitioning the month when needed. */
4852
5164
  #moveFocusTo(date) {
4853
5165
  this.#focusedDate = date;
4854
- if (this.#pendingStart) this.#previewDate = toISODateString(date);
4855
- this.#transitionTo(toISOMonthString(date), toISODateString(date));
5166
+ const iso = toISODateString(date);
5167
+ if (this.#pendingStart && this.#isSelectable(iso)) this.#previewDate = iso;
5168
+ this.#transitionTo(toISOMonthString(date), iso);
4856
5169
  }
4857
5170
  /** Renders `month`, then focuses the cell for `dateStr` (deferred if async). */
4858
5171
  #transitionTo(month, dateStr) {
4859
5172
  const isTransition = month !== this.#viewMonth;
5173
+ this.#focusTimer.clearAll();
4860
5174
  this.#viewMonth = month;
4861
5175
  this.#render();
4862
5176
  const focusCell = () => {
@@ -4888,17 +5202,19 @@ var DateRangePickerController = class extends Controller {
4888
5202
  const info = parseISOMonthString(this.#viewMonth);
4889
5203
  if (!info) return;
4890
5204
  const { year, month } = info;
5205
+ if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
4891
5206
  if (this.hasMonthLabelTarget) {
4892
5207
  const lang = document.documentElement.lang || "en";
4893
- const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
5208
+ const formatter = monthFormatter(lang);
4894
5209
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
4895
5210
  }
4896
5211
  const [rangeStart, rangeEnd] = this.#visualRange();
4897
5212
  const days = gridDays(year, month);
4898
5213
  const focusedStr = toISODateString(this.#focusedDate);
4899
5214
  const todayStr = toISODateString(/* @__PURE__ */ new Date());
5215
+ const cells = this.cellTargets;
4900
5216
  for (let i = 0; i < GRID_SIZE; i++) {
4901
- const el = this.cellTargets[i];
5217
+ const el = cells[i];
4902
5218
  const date = days[i];
4903
5219
  if (!el || !date) continue;
4904
5220
  const iso = toISODateString(date);
@@ -4907,8 +5223,8 @@ var DateRangePickerController = class extends Controller {
4907
5223
  el.setAttribute("data-outside", String(date.getMonth() !== month - 1));
4908
5224
  el.setAttribute("data-today", String(iso === todayStr));
4909
5225
  el.setAttribute("tabindex", iso === focusedStr ? "0" : "-1");
4910
- if (this.#outOfBounds(iso)) el.setAttribute("aria-disabled", "true");
4911
- else el.removeAttribute("aria-disabled");
5226
+ if (this.#isSelectable(iso)) el.removeAttribute("aria-disabled");
5227
+ else el.setAttribute("aria-disabled", "true");
4912
5228
  const isStart = !!rangeStart && iso === rangeStart;
4913
5229
  const isEnd = !!rangeEnd && iso === rangeEnd && rangeEnd !== rangeStart;
4914
5230
  const inside = !!rangeStart && !!rangeEnd && iso > rangeStart && iso < rangeEnd;
@@ -4922,6 +5238,16 @@ var DateRangePickerController = class extends Controller {
4922
5238
  )
4923
5239
  );
4924
5240
  }
5241
+ for (const extra of cells.slice(GRID_SIZE)) extra.setAttribute("tabindex", "-1");
5242
+ if (!cells.some((cell) => cell.getAttribute("tabindex") === "0")) {
5243
+ const fallback = cells.find((cell) => cell.getAttribute("data-outside") === "false") ?? cells[0];
5244
+ fallback?.setAttribute("tabindex", "0");
5245
+ }
5246
+ const previous = this.#announcedMonth;
5247
+ this.#announcedMonth = this.#viewMonth;
5248
+ if (previous !== null && previous !== this.#viewMonth) {
5249
+ this.dispatch("monthchange", { detail: { month: this.#viewMonth } });
5250
+ }
4925
5251
  }
4926
5252
  /** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
4927
5253
  #visualRange() {
@@ -4942,15 +5268,25 @@ var DateRangePickerController = class extends Controller {
4942
5268
  this.statusTarget.textContent = `${this.#startDate} \u2013 ${this.#endDate}`;
4943
5269
  }
4944
5270
  }
5271
+ /**
5272
+ * True when `iso` may be chosen as a range endpoint.
5273
+ *
5274
+ * The single availability question in the controller: the grid paint, the
5275
+ * preview, and both commit paths ask it, so a cell's `aria-disabled` and what
5276
+ * a click on that cell does are the same decision rather than two that have to
5277
+ * be kept in step.
5278
+ */
5279
+ #isSelectable(iso) {
5280
+ return !this.#outOfBounds(iso) && !this.#disabledDates.has(iso);
5281
+ }
4945
5282
  /** True when `iso` falls outside the `[min, max]` bounds. */
4946
5283
  #outOfBounds(iso) {
4947
5284
  if (this.minValue && iso < this.minValue) return true;
4948
5285
  if (this.maxValue && iso > this.maxValue) return true;
4949
5286
  return false;
4950
5287
  }
4951
- /** Clamps an ISO date string into `[min, max]`, or "" when unparseable. */
5288
+ /** Clamps a generated ISO date string into `[min, max]`. */
4952
5289
  #clampISO(iso) {
4953
- if (!iso) return "";
4954
5290
  if (this.minValue && iso < this.minValue) return this.minValue;
4955
5291
  if (this.maxValue && iso > this.maxValue) return this.maxValue;
4956
5292
  return iso;
@@ -4960,6 +5296,27 @@ var DateRangePickerController = class extends Controller {
4960
5296
  const clamped = this.#clampISO(toISODateString(date));
4961
5297
  return parseISODateString(clamped);
4962
5298
  }
5299
+ /**
5300
+ * Intersects a preset with `[min, max]`, rejecting a disjoint interval.
5301
+ *
5302
+ * Only the bounds narrow a preset here: `disabledDates` excludes single days,
5303
+ * not sub-intervals, so it cannot shrink one to a still-contiguous range. A
5304
+ * day it excludes is still refused as an endpoint — the caller checks that —
5305
+ * but a preset that merely spans one keeps its span.
5306
+ */
5307
+ #intersectRange(range) {
5308
+ const start = this.minValue && range.start < this.minValue ? this.minValue : range.start;
5309
+ const end = this.maxValue && range.end > this.maxValue ? this.maxValue : range.end;
5310
+ return start <= end ? { start, end } : null;
5311
+ }
5312
+ /** Removes provisional range state before Turbo freezes a cached snapshot. */
5313
+ #rewindForCache() {
5314
+ this.#focusTimer.clearAll();
5315
+ if (!this.#pendingStart && !this.#previewDate) return;
5316
+ this.#pendingStart = "";
5317
+ this.#previewDate = "";
5318
+ this.#render();
5319
+ }
4963
5320
  /** Resolves the cell element from an event target, or null. */
4964
5321
  #cellFrom(target) {
4965
5322
  return target?.closest(
@@ -4978,6 +5335,12 @@ function shiftMonthClamped(date, delta) {
4978
5335
  target.setDate(Math.min(date.getDate(), lastDay));
4979
5336
  return target;
4980
5337
  }
5338
+ function shiftYearClamped(date, delta) {
5339
+ const target = new Date(date.getFullYear() + delta, date.getMonth(), 1);
5340
+ const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
5341
+ target.setDate(Math.min(date.getDate(), lastDay));
5342
+ return target;
5343
+ }
4981
5344
  function gridDays(year, month) {
4982
5345
  const first = new Date(year, month - 1, 1);
4983
5346
  const start = new Date(first);
@@ -4994,6 +5357,18 @@ function normalizeISO(value) {
4994
5357
  const date = parseISODateString(value.trim());
4995
5358
  return date ? toISODateString(date) : "";
4996
5359
  }
5360
+ function orderRange(start, end) {
5361
+ return start && end && end < start ? [end, start] : [start, end];
5362
+ }
5363
+ function monthFormatter(locale) {
5364
+ const options = { month: "long", year: "numeric" };
5365
+ try {
5366
+ return new Intl.DateTimeFormat(locale, options);
5367
+ } catch (error) {
5368
+ if (!(error instanceof RangeError)) throw error;
5369
+ return new Intl.DateTimeFormat("en", options);
5370
+ }
5371
+ }
4997
5372
  function computePreset(name) {
4998
5373
  const today = /* @__PURE__ */ new Date();
4999
5374
  const todayStr = toISODateString(today);
@@ -5965,18 +6340,38 @@ var FileDropzoneController = class extends Controller {
5965
6340
  static events = ["change", "reject"];
5966
6341
  /** Selected files paired with their rendered item and any preview objectURL. */
5967
6342
  #entries = [];
6343
+ /** Prevents initial and teardown target callbacks from binding outside controller lifetime. */
6344
+ #connected = false;
5968
6345
  /** Wires file removal as a delegated listener on the list container. */
5969
6346
  connect() {
5970
- if (this.hasListTarget) this.listTarget.addEventListener("click", this.#onItemClick);
6347
+ this.#connected = true;
6348
+ if (this.hasListTarget) this.#bindList(this.listTarget);
5971
6349
  }
5972
6350
  /** Revokes any outstanding preview URLs so none leaks across navigations. */
5973
6351
  disconnect() {
5974
- if (this.hasListTarget) this.listTarget.removeEventListener("click", this.#onItemClick);
6352
+ this.#connected = false;
6353
+ for (const list of this.listTargets) list.removeEventListener("click", this.#onItemClick);
5975
6354
  for (const entry of this.#entries) {
5976
6355
  if (entry.url) URL.revokeObjectURL(entry.url);
5977
6356
  }
5978
6357
  this.#entries.length = 0;
5979
6358
  }
6359
+ /** Rebinds removal and restores client-only previews when Turbo replaces the list target. */
6360
+ listTargetConnected(list) {
6361
+ if (!this.#connected) return;
6362
+ this.#bindList(list);
6363
+ }
6364
+ /** Releases only the list target that actually disconnected. */
6365
+ listTargetDisconnected(list) {
6366
+ list.removeEventListener("click", this.#onItemClick);
6367
+ }
6368
+ /** Binds delegated removal once and moves live preview items into the current list. */
6369
+ #bindList(list) {
6370
+ list.addEventListener("click", this.#onItemClick);
6371
+ for (const entry of this.#entries) {
6372
+ if (!list.contains(entry.item)) list.appendChild(entry.item);
6373
+ }
6374
+ }
5980
6375
  /** Opens the native file dialog. Bound via `data-action` (trigger click). */
5981
6376
  openDialog() {
5982
6377
  this.inputTarget.click();
@@ -6008,8 +6403,11 @@ var FileDropzoneController = class extends Controller {
6008
6403
  * an item is appended without waiting on Stimulus to wire a freshly created element.
6009
6404
  */
6010
6405
  #onItemClick = (event) => {
6406
+ const list = event.currentTarget;
6011
6407
  const button = event.target.closest("button");
6012
- if (!button || !this.hasListTarget || !this.listTarget.contains(button)) return;
6408
+ if (!this.#connected || !list || !button || !this.hasListTarget || list !== this.listTarget || !list.contains(button)) {
6409
+ return;
6410
+ }
6013
6411
  const index = this.#entries.findIndex((entry) => entry.item.contains(button));
6014
6412
  if (index !== -1) this.#removeAt(index);
6015
6413
  };
@@ -7403,14 +7801,22 @@ var HoverCardController = class extends Controller {
7403
7801
  return this.hasCardTarget && !this.cardTarget.hidden;
7404
7802
  }
7405
7803
  };
7804
+ var DEFAULT_ACTIVITY_EVENTS = [
7805
+ "mousemove",
7806
+ "mousedown",
7807
+ "keydown",
7808
+ "wheel",
7809
+ "touchstart",
7810
+ "scroll"
7811
+ ];
7406
7812
  var IdleController = class extends Controller {
7407
7813
  static values = {
7408
7814
  timeout: { type: Number, default: 9e5 },
7409
7815
  promptBefore: { type: Number, default: 0 },
7410
- events: {
7411
- type: Array,
7412
- default: ["mousemove", "mousedown", "keydown", "wheel", "touchstart", "scroll"]
7413
- }
7816
+ // A JSON list read through `parseStringList` rather than Stimulus's `Array`
7817
+ // type: that reader throws out of the value observer before any callback
7818
+ // runs, so one malformed attribute would stop the detector connecting.
7819
+ events: { type: String, default: "" }
7414
7820
  };
7415
7821
  static events = ["prompt", "idle", "active"];
7416
7822
  #timeouts = new SafeTimeout();
@@ -7441,7 +7847,7 @@ var IdleController = class extends Controller {
7441
7847
  this.#idle = false;
7442
7848
  this.#prompted = false;
7443
7849
  this.element.removeAttribute("data-idle");
7444
- this.#boundEvents = [...this.eventsValue];
7850
+ this.#boundEvents = parseStringList(this.eventsValue, DEFAULT_ACTIVITY_EVENTS);
7445
7851
  for (const type of this.#boundEvents) {
7446
7852
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
7447
7853
  }
@@ -7754,7 +8160,7 @@ var IntersectionWatcher = class {
7754
8160
  };
7755
8161
 
7756
8162
  // src/controllers/intersection_controller.ts
7757
- var RATIO_PROPERTY = "--stimeo--intersection-ratio";
8163
+ var RATIO_PROPERTY2 = "--stimeo--intersection-ratio";
7758
8164
  var RATIO_EPSILON = 0.01;
7759
8165
  var IntersectionController = class extends Controller {
7760
8166
  static values = {
@@ -7779,7 +8185,7 @@ var IntersectionController = class extends Controller {
7779
8185
  const ratio = entry.intersectionRatio;
7780
8186
  const threshold = this.#effectiveThreshold;
7781
8187
  const intersecting = threshold > 0 ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON : entry.isIntersecting;
7782
- this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
8188
+ this.element.style.setProperty(RATIO_PROPERTY2, String(ratio));
7783
8189
  this.dispatch("change", { detail: { intersecting, ratio } });
7784
8190
  this.#syncIntersecting(intersecting, ratio, entry);
7785
8191
  this.#syncPassed(!intersecting && isBeforeRootStart(entry));
@@ -9418,10 +9824,148 @@ var MeterController = class extends Controller {
9418
9824
  }
9419
9825
  };
9420
9826
 
9827
+ // src/utils/chip_row.ts
9828
+ var ChipRow = class {
9829
+ #directionElement;
9830
+ #getItems;
9831
+ #getButton;
9832
+ #onRemove;
9833
+ #focusAfterEnd;
9834
+ #roving = new RovingTabindex(() => this.buttons);
9835
+ #container = null;
9836
+ constructor(options) {
9837
+ this.#directionElement = options.directionElement;
9838
+ this.#getItems = options.getItems;
9839
+ this.#getButton = options.getButton ?? ((item) => item.querySelector("button"));
9840
+ this.#onRemove = options.onRemove;
9841
+ this.#focusAfterEnd = options.focusAfterEnd;
9842
+ }
9843
+ /**
9844
+ * Binds delegation to `container`; any row currently bound is released first.
9845
+ * Idempotent for the same node, so target callbacks may call it freely.
9846
+ */
9847
+ connect(container) {
9848
+ if (this.#container === container) return;
9849
+ this.disconnect();
9850
+ this.#container = container;
9851
+ container.addEventListener("click", this.#onClick);
9852
+ container.addEventListener("keydown", this.#onKeydown);
9853
+ }
9854
+ /**
9855
+ * Releases the current row. When `container` is supplied, a stale disconnect
9856
+ * callback cannot tear listeners off a newer replacement target.
9857
+ */
9858
+ disconnect(container) {
9859
+ const current = this.#container;
9860
+ if (!current || container !== void 0 && current !== container) return;
9861
+ current.removeEventListener("click", this.#onClick);
9862
+ current.removeEventListener("keydown", this.#onKeydown);
9863
+ this.#container = null;
9864
+ }
9865
+ /** Current remove buttons, at most one consumer-resolved button per chip item. */
9866
+ get buttons() {
9867
+ return this.#entries.map(({ button }) => button);
9868
+ }
9869
+ /** Number of currently navigable remove buttons. */
9870
+ get length() {
9871
+ return this.buttons.length;
9872
+ }
9873
+ /** Declared-item index of the last navigable chip, or `-1` for an empty row. */
9874
+ get lastIndex() {
9875
+ return this.#entries.at(-1)?.itemIndex ?? -1;
9876
+ }
9877
+ /** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
9878
+ ensureTabStop() {
9879
+ const buttons = this.#entries.map(({ button }) => button);
9880
+ const active = buttons.findIndex((button) => button.tabIndex === 0);
9881
+ const count = buttons.filter((button) => button.tabIndex === 0).length;
9882
+ if (buttons.length > 0 && count !== 1) {
9883
+ this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
9884
+ }
9885
+ }
9886
+ /** Focuses the last chip, returning false for an empty row. */
9887
+ focusLast() {
9888
+ const buttons = this.#entries.map(({ button }) => button);
9889
+ const last = buttons.length - 1;
9890
+ if (last < 0) return false;
9891
+ this.#roving.setActive(last, { focus: true, items: buttons });
9892
+ return true;
9893
+ }
9894
+ /**
9895
+ * Focuses the chip that followed a removed index, or the new last chip.
9896
+ * Returns false when removal emptied the row so the consumer can rescue focus.
9897
+ */
9898
+ focusAfterRemoval(index) {
9899
+ const entries = this.#entries;
9900
+ if (entries.length === 0) return false;
9901
+ const following = entries.findIndex((entry) => entry.itemIndex >= index);
9902
+ this.#roving.setActive(following === -1 ? entries.length - 1 : following, {
9903
+ focus: true,
9904
+ items: entries.map(({ button }) => button)
9905
+ });
9906
+ return true;
9907
+ }
9908
+ /** Current remove-button entries, retaining each button's declared item index. */
9909
+ get #entries() {
9910
+ return this.#getItems().flatMap((item, itemIndex) => {
9911
+ const button = this.#getButton(item);
9912
+ return button ? [{ button, itemIndex }] : [];
9913
+ });
9914
+ }
9915
+ /** Resolves a delegated event to the remove button owned by a declared chip. */
9916
+ #entry(event) {
9917
+ const target = event.target;
9918
+ if (!(target instanceof Element)) return null;
9919
+ const button = target.closest("button");
9920
+ const entries = this.#entries;
9921
+ const buttonIndex = entries.findIndex((entry2) => entry2.button === button);
9922
+ if (buttonIndex === -1) return null;
9923
+ const entry = entries[buttonIndex];
9924
+ return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
9925
+ }
9926
+ /** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
9927
+ #onClick = (event) => {
9928
+ const entry = this.#entry(event);
9929
+ if (!entry) return;
9930
+ this.#onRemove(entry.itemIndex);
9931
+ };
9932
+ /** Applies the shared logical-arrow and removal policy within the chip row. */
9933
+ #onKeydown = (event) => {
9934
+ if (event.defaultPrevented || isReservedArrowChord(event)) return;
9935
+ const entry = this.#entry(event);
9936
+ if (!entry) return;
9937
+ const buttons = entry.buttons;
9938
+ const index = entry.buttonIndex;
9939
+ switch (logicalArrowKey(event.key, this.#directionElement)) {
9940
+ case "ArrowLeft":
9941
+ if (index > 0) {
9942
+ event.preventDefault();
9943
+ this.#roving.setActive(index - 1, { focus: true, items: buttons });
9944
+ }
9945
+ break;
9946
+ case "ArrowRight":
9947
+ event.preventDefault();
9948
+ if (index < buttons.length - 1) {
9949
+ this.#roving.setActive(index + 1, { focus: true, items: buttons });
9950
+ } else {
9951
+ this.#focusAfterEnd();
9952
+ }
9953
+ break;
9954
+ case "Delete":
9955
+ case "Backspace":
9956
+ event.preventDefault();
9957
+ this.#onRemove(entry.itemIndex);
9958
+ break;
9959
+ }
9960
+ };
9961
+ };
9962
+
9421
9963
  // src/utils/tabindex_loan.ts
9422
9964
  var TabindexLoan = class {
9423
9965
  #value;
9424
9966
  #lent = /* @__PURE__ */ new Set();
9967
+ /** Returns live loans before Turbo can copy them into its page snapshot. */
9968
+ #beforeCache = new BeforeCacheReset(() => this.returnAll());
9425
9969
  /**
9426
9970
  * @param value - the `tabindex` to lend. `"-1"` (the default) is
9427
9971
  * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
@@ -9435,6 +9979,7 @@ var TabindexLoan = class {
9435
9979
  if (element.hasAttribute("tabindex")) return;
9436
9980
  element.setAttribute("tabindex", this.#value);
9437
9981
  this.#lent.add(element);
9982
+ this.#beforeCache.activate();
9438
9983
  }
9439
9984
  /** Takes back every loan whose value is still the one that was lent. */
9440
9985
  returnAll() {
@@ -9442,6 +9987,7 @@ var TabindexLoan = class {
9442
9987
  if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
9443
9988
  }
9444
9989
  this.#lent.clear();
9990
+ this.#beforeCache.deactivate();
9445
9991
  }
9446
9992
  };
9447
9993
 
@@ -9454,21 +10000,26 @@ var MultiSelectController = class extends Controller {
9454
10000
  "tags",
9455
10001
  "tag",
9456
10002
  "tagTemplate",
9457
- "status",
10003
+ "label",
10004
+ "remove",
9458
10005
  "fields"
9459
10006
  ];
9460
10007
  static values = {
9461
10008
  max: { type: Number, default: 0 },
9462
10009
  name: { type: String, default: "options[]" },
9463
- form: { type: String, default: "" }
10010
+ form: { type: String, default: "" },
10011
+ announceText: { type: String, default: "" },
10012
+ announceRemovedText: { type: String, default: "" }
9464
10013
  };
9465
10014
  static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
9466
- static events = ["change", "filter"];
10015
+ static events = ["change", "filter", "reconcile"];
9467
10016
  /** Stable id of the active option; the current target is resolved from the DOM. */
9468
10017
  #activeOptionId = null;
9469
10018
  /** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
9470
10019
  #tabindex = new TabindexLoan();
9471
- /** Prevents initial/teardown target callbacks from mutating authored DOM. */
10020
+ /** Last reconciled selection, used to distinguish state changes from derived-DOM repair. */
10021
+ #selectionValues = [];
10022
+ /** Whether initial normalization finished, so a fields callback cannot mirror stale state. */
9472
10023
  #connected = false;
9473
10024
  /** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
9474
10025
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
@@ -9487,17 +10038,26 @@ var MultiSelectController = class extends Controller {
9487
10038
  this.filter();
9488
10039
  }
9489
10040
  });
9490
- #roving = new RovingTabindex(() => this.#removeButtons);
10041
+ /** Whether this connection already reported its unusable chip template. */
10042
+ #warnedTemplate = false;
10043
+ /** Shared delegated interaction for the replaceable row of removable chips. */
10044
+ #chipRow = new ChipRow({
10045
+ directionElement: this.element,
10046
+ getItems: () => this.tagTargets,
10047
+ getButton: (tag) => tag.querySelector('button[data-stimeo--multi-select-target~="remove"]'),
10048
+ onRemove: (index) => this.#removeTagAt(index),
10049
+ focusAfterEnd: () => this.#focusInput()
10050
+ });
9491
10051
  /** Starts closed, syncs chips for any pre-selected options, and listens out. */
9492
10052
  connect() {
9493
- if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
9494
- this.#normalizeSelection();
10053
+ this.#warnedTemplate = false;
10054
+ this.#normalizeSelection([]);
9495
10055
  this.close();
9496
10056
  if (this.hasTagsTarget) {
9497
- this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
9498
- this.tagsTarget.addEventListener("click", this.#onTagClick);
10057
+ this.#chipRow.connect(this.tagsTarget);
9499
10058
  this.#rebuildTags();
9500
10059
  }
10060
+ this.#selectionValues = this.#values;
9501
10061
  this.#syncFields();
9502
10062
  document.addEventListener("click", this.#onOutsideClick, true);
9503
10063
  this.#connected = true;
@@ -9509,10 +10069,16 @@ var MultiSelectController = class extends Controller {
9509
10069
  * cleared before deriving afresh to avoid duplicates.
9510
10070
  */
9511
10071
  #rebuildTags() {
9512
- if (!this.hasTagsTarget) return;
10072
+ if (!this.hasTagsTarget || !this.hasTagTemplateTarget) return;
10073
+ const fragments = [];
10074
+ for (const option of this.#selectedOptions) {
10075
+ const fragment = this.#buildTag(option);
10076
+ if (!fragment) return;
10077
+ fragments.push(fragment);
10078
+ }
9513
10079
  for (const tag of this.tagTargets) tag.remove();
9514
- for (const option of this.#selectedOptions) this.#appendTag(option);
9515
- if (this.#removeButtons.length > 0) this.#roving.setActive(0);
10080
+ this.tagsTarget.append(...fragments);
10081
+ this.#chipRow.ensureTabStop();
9516
10082
  }
9517
10083
  /** Tears down document and chip listeners on disconnect (Turbo included). */
9518
10084
  disconnect() {
@@ -9520,10 +10086,7 @@ var MultiSelectController = class extends Controller {
9520
10086
  this.#reconcile.cancel();
9521
10087
  this.#composition.disconnect();
9522
10088
  this.#ignorePostCompositionInput = false;
9523
- if (this.hasTagsTarget) {
9524
- this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
9525
- this.tagsTarget.removeEventListener("click", this.#onTagClick);
9526
- }
10089
+ this.#chipRow.disconnect();
9527
10090
  document.removeEventListener("click", this.#onOutsideClick, true);
9528
10091
  this.#releaseTabindex();
9529
10092
  }
@@ -9533,18 +10096,43 @@ var MultiSelectController = class extends Controller {
9533
10096
  }
9534
10097
  /** Cleans a removed target and reconciles active state against the surviving DOM. */
9535
10098
  optionTargetDisconnected(option) {
9536
- if (!this.#connected) return;
9537
10099
  option.removeAttribute("data-active");
9538
10100
  this.#scheduleOptionReconcile();
9539
10101
  }
9540
- /** Schedules one reconciliation after all callbacks in the mutation batch. */
9541
- #scheduleOptionReconcile() {
9542
- this.#reconcile.schedule();
10102
+ /** Rebinds delegated chip interaction when Turbo replaces the tags container. */
10103
+ tagsTargetConnected(tags) {
10104
+ this.#chipRow.connect(tags);
10105
+ this.#scheduleOptionReconcile();
9543
10106
  }
9544
- /**
9545
- * Keeps a surviving/same-id active target, otherwise falls back to the first
9546
- * visible one — and brings the derived state back in line with the new option set.
9547
- *
10107
+ /** Releases only the row that actually disconnected, never a newer replacement. */
10108
+ tagsTargetDisconnected(tags) {
10109
+ this.#chipRow.disconnect(tags);
10110
+ this.#scheduleOptionReconcile();
10111
+ }
10112
+ /** Seeds a fields target inserted after connect from the current selection. */
10113
+ fieldsTargetConnected() {
10114
+ if (this.#connected) this.#syncFields();
10115
+ }
10116
+ /** Reconciles a runtime max change, including dropping any newly invalid overflow. */
10117
+ maxValueChanged() {
10118
+ this.#scheduleOptionReconcile();
10119
+ }
10120
+ /** Rebuilds submitted fields when their public name changes at runtime. */
10121
+ nameValueChanged() {
10122
+ this.#scheduleOptionReconcile();
10123
+ }
10124
+ /** Rebuilds submitted fields when their associated form changes at runtime. */
10125
+ formValueChanged() {
10126
+ this.#scheduleOptionReconcile();
10127
+ }
10128
+ /** Schedules one reconciliation after all callbacks in the mutation batch. */
10129
+ #scheduleOptionReconcile() {
10130
+ this.#reconcile.schedule();
10131
+ }
10132
+ /**
10133
+ * Keeps a surviving/same-id active target, otherwise falls back to the first
10134
+ * visible one — and brings the derived state back in line with the new option set.
10135
+ *
9548
10136
  * The baseline pass fills in any missing `aria-selected`, and the chips and
9549
10137
  * hidden fields are re-derived from it, because the options are the truth source
9550
10138
  * for the selection. The chips are rebuilt **only when the selected value set
@@ -9559,7 +10147,17 @@ var MultiSelectController = class extends Controller {
9559
10147
  const next = this.#isClosed ? null : active && !active.hidden ? active : visible[0] ?? null;
9560
10148
  this.#setActive(next);
9561
10149
  this.#reflectEmpty();
9562
- this.#normalizeSelection();
10150
+ const previous = this.#selectionValues;
10151
+ const previousLabels = new Map(
10152
+ this.tagTargets.map((tag) => [
10153
+ tag.dataset.value ?? "",
10154
+ (tag.querySelector('[data-stimeo--multi-select-target~="label"]')?.textContent ?? "").trim()
10155
+ ])
10156
+ );
10157
+ const priority = [
10158
+ .../* @__PURE__ */ new Set([...this.tagTargets.map((tag) => tag.dataset.value ?? ""), ...previous])
10159
+ ];
10160
+ this.#normalizeSelection(priority);
9563
10161
  const selected = this.#selectedOptions;
9564
10162
  const nextValues = selected.map((option) => this.#optionValue(option)).sort();
9565
10163
  const tagValues = this.tagTargets.map((tag) => tag.dataset.value ?? "").sort();
@@ -9567,18 +10165,51 @@ var MultiSelectController = class extends Controller {
9567
10165
  if (unchanged) this.#refreshTagLabels(selected);
9568
10166
  else this.#rebuildTags();
9569
10167
  this.#syncFields();
10168
+ const values = this.#values;
10169
+ const changed = !this.#sameSelection(previous, values);
10170
+ this.#selectionValues = values;
10171
+ if (!changed) return;
10172
+ const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
10173
+ for (const value of previous) {
10174
+ if (!values.includes(value)) {
10175
+ this.#announceTransition(false, previousLabels.get(value) || value, value, values.length);
10176
+ }
10177
+ }
10178
+ for (const value of values) {
10179
+ if (previous.includes(value)) continue;
10180
+ const option = options.get(value);
10181
+ this.#announceTransition(true, this.#optionLabel(option), value, values.length);
10182
+ }
10183
+ this.dispatch("reconcile", { detail: { values } });
9570
10184
  }
9571
10185
  /**
9572
- * Gives every option an explicit `aria-selected`, without changing which ones
9573
- * the author chose. An absent value means "not selectable" in ARIA, so a
9574
- * forgotten attribute hides a selectable option. Several `true` is the normal
9575
- * case here — the list is `aria-multiselectable` — so nothing is dropped.
10186
+ * Gives every option an explicit `aria-selected` and enforces the current cap.
10187
+ * Priority preserves existing chip order at runtime; a fresh connection passes
10188
+ * no priority, so deterministic option DOM order chooses the initial survivors.
9576
10189
  */
9577
- #normalizeSelection() {
9578
- for (const option of this.optionTargets) {
9579
- if (option.getAttribute("aria-selected") !== "true") {
9580
- option.setAttribute("aria-selected", "false");
10190
+ #normalizeSelection(priority) {
10191
+ const selected = this.optionTargets.filter(
10192
+ (option) => option.getAttribute("aria-selected") === "true"
10193
+ );
10194
+ const limit = this.#selectionLimit;
10195
+ const kept = /* @__PURE__ */ new Set();
10196
+ if (limit === 0) {
10197
+ for (const option of selected) kept.add(option);
10198
+ } else {
10199
+ for (const value of priority) {
10200
+ if (kept.size >= limit) break;
10201
+ const option = selected.find(
10202
+ (candidate) => !kept.has(candidate) && this.#optionValue(candidate) === value
10203
+ );
10204
+ if (option) kept.add(option);
9581
10205
  }
10206
+ for (const option of selected) {
10207
+ if (kept.size >= limit) break;
10208
+ kept.add(option);
10209
+ }
10210
+ }
10211
+ for (const option of this.optionTargets) {
10212
+ option.setAttribute("aria-selected", String(kept.has(option)));
9582
10213
  }
9583
10214
  }
9584
10215
  /**
@@ -9602,6 +10233,7 @@ var MultiSelectController = class extends Controller {
9602
10233
  }
9603
10234
  /** Filters confirmed input text, opens, and re-seeds the active option. */
9604
10235
  filter(event) {
10236
+ if (!this.hasInputTarget) return;
9605
10237
  if (event && this.#ignorePostCompositionInput) {
9606
10238
  this.#ignorePostCompositionInput = false;
9607
10239
  return;
@@ -9626,10 +10258,14 @@ var MultiSelectController = class extends Controller {
9626
10258
  * opening is skipped entirely, where {@link close} still closes.
9627
10259
  */
9628
10260
  open() {
9629
- if (!this.hasListTarget || !this.hasInputTarget) return;
10261
+ if (!this.hasListTarget || !this.hasInputTarget) {
10262
+ this.#reflectEmpty();
10263
+ return;
10264
+ }
9630
10265
  this.listTarget.hidden = false;
9631
10266
  this.inputTarget.setAttribute("aria-expanded", "true");
9632
10267
  if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
10268
+ this.#reflectEmpty();
9633
10269
  }
9634
10270
  /**
9635
10271
  * Closes the list and clears the active option.
@@ -9643,16 +10279,16 @@ var MultiSelectController = class extends Controller {
9643
10279
  * active option, so only the `aria-expanded` write is guarded.
9644
10280
  */
9645
10281
  close() {
9646
- if (!this.hasListTarget) return;
9647
- this.listTarget.hidden = true;
10282
+ if (this.hasListTarget) this.listTarget.hidden = true;
9648
10283
  this.#setActive(null);
9649
- if (!this.hasInputTarget) return;
9650
- this.inputTarget.setAttribute("aria-expanded", "false");
10284
+ if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
10285
+ this.#reflectEmpty();
9651
10286
  }
9652
10287
  /** Routes input keyboard interaction per the multi-select combobox model. */
9653
10288
  onKeydown(event) {
9654
10289
  if (event.defaultPrevented) return;
9655
10290
  if (isReservedArrowChord(event)) return;
10291
+ if (!this.hasInputTarget) return;
9656
10292
  if (this.#composition.isComposing(event)) return;
9657
10293
  this.#reconcileActiveForInteraction();
9658
10294
  switch (logicalArrowKey(event.key, this.element)) {
@@ -9695,17 +10331,17 @@ var MultiSelectController = class extends Controller {
9695
10331
  break;
9696
10332
  case "Backspace":
9697
10333
  if (this.inputTarget.value === "") {
9698
- const buttons = this.#removeButtons;
9699
- if (buttons.length > 0) {
10334
+ const last = this.#chipRow.lastIndex;
10335
+ if (last >= 0) {
9700
10336
  event.preventDefault();
9701
- this.#removeTagAt(buttons.length - 1);
10337
+ this.#removeTagAt(last, "input");
9702
10338
  }
9703
10339
  }
9704
10340
  break;
9705
10341
  case "ArrowLeft":
9706
- if (this.inputTarget.value === "" && this.#removeButtons.length > 0) {
10342
+ if (this.inputTarget.value === "" && this.#chipRow.length > 0) {
9707
10343
  event.preventDefault();
9708
- this.#roving.setActive(this.#removeButtons.length - 1, { focus: true });
10344
+ this.#chipRow.focusLast();
9709
10345
  }
9710
10346
  break;
9711
10347
  case "Tab":
@@ -9743,8 +10379,8 @@ var MultiSelectController = class extends Controller {
9743
10379
  * Unlike the other {@link #focusInput} callers, the element that held focus has
9744
10380
  * just left the DOM, so "leave it alone" is not an option — the browser already
9745
10381
  * dropped it to `<body>`. The root borrows a `tabindex="-1"` just-in-time (not a
9746
- * Tab stop, handed back on teardown). Focus that landed on a real element is
9747
- * left alone, so a chip removed out of band never steals it.
10382
+ * Tab stop, handed back before Turbo caches or on teardown). Focus that landed
10383
+ * on a real element is left alone, so a chip removed out of band never steals it.
9748
10384
  */
9749
10385
  #focusAfterLastTag() {
9750
10386
  if (this.hasInputTarget) {
@@ -9767,43 +10403,40 @@ var MultiSelectController = class extends Controller {
9767
10403
  #releaseTabindex() {
9768
10404
  this.#tabindex.returnAll();
9769
10405
  }
9770
- /**
9771
- * Removes the chip whose remove button was clicked, deselecting its option.
9772
- * Delegated on the tags container (like `#onTagKeydown`) rather than bound
9773
- * per chip via `data-action`, so it works the instant a chip is appended without
9774
- * waiting on Stimulus to wire a freshly created element.
9775
- */
9776
- #onTagClick = (event) => {
9777
- const button = event.target.closest("button");
9778
- if (!button || !this.tagsTarget.contains(button)) return;
9779
- const index = this.#removeButtons.indexOf(button);
9780
- if (index !== -1) this.#removeTagAt(index);
9781
- };
9782
10406
  /** Moves the active option by `delta` among visible options, wrapping. */
9783
10407
  #moveActive(delta) {
9784
10408
  const visible = this.#visibleOptions;
9785
- if (visible.length === 0) return;
9786
- const current = this.#activeOption ? visible.indexOf(this.#activeOption) : -1;
10409
+ const active = this.#activeOption;
10410
+ const current = visible.indexOf(active);
9787
10411
  const candidate = current === -1 ? delta > 0 ? 0 : visible.length - 1 : current + delta;
9788
10412
  const next = (candidate + visible.length) % visible.length;
9789
10413
  this.#setActive(visible[next] ?? null);
9790
10414
  }
9791
- /** Selects/deselects `option`, honoring `max`, and syncs chip + live region. */
10415
+ /** Selects/deselects `option`, honoring `max`, and syncs chip + announcement. */
9792
10416
  #toggleSelection(option) {
9793
10417
  const selected = option.getAttribute("aria-selected") === "true";
9794
- if (!selected && this.maxValue > 0 && this.#selectedOptions.length >= this.maxValue) {
10418
+ const limit = this.#selectionLimit;
10419
+ if (!selected && limit > 0 && this.#selectedOptions.length >= limit) {
9795
10420
  return;
9796
10421
  }
9797
- option.setAttribute("aria-selected", String(!selected));
9798
10422
  if (selected) {
10423
+ option.setAttribute("aria-selected", "false");
9799
10424
  this.#removeTagFor(option);
9800
10425
  } else {
9801
- this.#appendTag(option);
10426
+ if (this.hasTagTemplateTarget && !this.#appendTag(option)) return;
10427
+ option.setAttribute("aria-selected", "true");
9802
10428
  }
9803
- this.#announce(this.#optionLabel(option));
9804
10429
  this.#refreshRoving();
9805
10430
  this.#syncFields();
9806
- this.dispatch("change", { detail: { values: this.#values } });
10431
+ const values = this.#values;
10432
+ this.#selectionValues = values;
10433
+ this.#announceTransition(
10434
+ !selected,
10435
+ this.#optionLabel(option),
10436
+ this.#optionValue(option),
10437
+ values.length
10438
+ );
10439
+ this.dispatch("change", { detail: { values } });
9807
10440
  }
9808
10441
  /**
9809
10442
  * Re-reads each chip's label from its option, in place.
@@ -9818,31 +10451,81 @@ var MultiSelectController = class extends Controller {
9818
10451
  const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
9819
10452
  for (const tag of this.tagTargets) {
9820
10453
  const option = options.get(tag.dataset.value ?? "");
9821
- if (!option) continue;
9822
10454
  const text = this.#optionLabel(option);
9823
- const label = tag.querySelector('[data-multi-select-slot="label"]');
9824
- if (label && label.textContent !== text) label.textContent = text;
9825
- const button = tag.querySelector("button");
9826
- const name = `Remove ${text}`;
9827
- if (button && button.getAttribute("aria-label") !== name) {
9828
- button.setAttribute("aria-label", name);
9829
- }
10455
+ const label = tag.querySelector('[data-stimeo--multi-select-target~="label"]');
10456
+ const button = tag.querySelector(
10457
+ 'button[data-stimeo--multi-select-target~="remove"]'
10458
+ );
10459
+ const name = this.#removeName(text, this.#optionValue(option));
10460
+ if (!label || !button || !name) continue;
10461
+ if (label.textContent !== text) label.textContent = text;
10462
+ if (button.getAttribute("aria-label") !== name) button.setAttribute("aria-label", name);
9830
10463
  }
9831
10464
  }
9832
10465
  /** Builds one chip from the template for `option`. */
9833
10466
  #appendTag(option) {
9834
- if (!this.hasTagTemplateTarget || !this.hasTagsTarget) return;
10467
+ if (!this.hasTagsTarget) {
10468
+ this.#warnTemplate('a "tags" target to append the chip to');
10469
+ return false;
10470
+ }
10471
+ const fragment = this.#buildTag(option);
10472
+ if (!fragment) return false;
10473
+ this.tagsTarget.appendChild(fragment);
10474
+ return true;
10475
+ }
10476
+ /**
10477
+ * Builds one fully named chip without mutating the live tag row, or `null`
10478
+ * when the authored template cannot produce one. Both callers establish the
10479
+ * template first: a field authored without `tagTemplate` renders no chips at
10480
+ * all — a supported configuration — and never reaches here.
10481
+ */
10482
+ #buildTag(option) {
9835
10483
  const fragment = this.tagTemplateTarget.content.cloneNode(true);
9836
- const tag = fragment.querySelector('[data-stimeo--multi-select-target="tag"]');
9837
- const label = fragment.querySelector('[data-multi-select-slot="label"]');
9838
- const button = fragment.querySelector("button");
9839
- if (!tag || !button) return;
10484
+ const tag = fragment.querySelector('[data-stimeo--multi-select-target~="tag"]');
10485
+ const label = fragment.querySelector(
10486
+ '[data-stimeo--multi-select-target~="label"]'
10487
+ );
10488
+ const button = fragment.querySelector(
10489
+ 'button[data-stimeo--multi-select-target~="remove"]'
10490
+ );
9840
10491
  const text = this.#optionLabel(option);
9841
- tag.dataset.value = this.#optionValue(option);
9842
- if (label) label.textContent = text;
9843
- button.setAttribute("aria-label", `Remove ${text}`);
10492
+ const value = this.#optionValue(option);
10493
+ const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
10494
+ if (!tag) return this.#warnTemplate('a "tag" target');
10495
+ if (!label) return this.#warnTemplate('a "label" target');
10496
+ if (!button) return this.#warnTemplate('a "remove" target <button>');
10497
+ if (removeName === "") {
10498
+ return this.#warnTemplate('a non-empty aria-label on its "remove" target');
10499
+ }
10500
+ tag.dataset.value = value;
10501
+ label.textContent = text;
10502
+ button.setAttribute("aria-label", fillTemplate(removeName, { label: text, value }));
9844
10503
  button.tabIndex = -1;
9845
- this.tagsTarget.appendChild(fragment);
10504
+ return fragment;
10505
+ }
10506
+ /**
10507
+ * Reports an unusable chip template to the author, once per connection.
10508
+ *
10509
+ * The selection itself stays untouched — no `aria-selected`, chip, hidden
10510
+ * field, announcement, or event moves. Without this line the only symptom is
10511
+ * a listbox whose options refuse to select, and the two causes the Inspector
10512
+ * cannot see statically (a name that renders empty from a missing
10513
+ * translation, a server-rendered template) would have no diagnostic anywhere.
10514
+ */
10515
+ #warnTemplate(missing) {
10516
+ if (!this.#warnedTemplate) {
10517
+ this.#warnedTemplate = true;
10518
+ console.warn(
10519
+ `Stimeo UI: "${this.identifier}" changed no selection because its chip template lacks ${missing}.`
10520
+ );
10521
+ }
10522
+ return null;
10523
+ }
10524
+ /** Expands the current template's localized remove-button name. */
10525
+ #removeName(label, value) {
10526
+ if (!this.hasTagTemplateTarget) return null;
10527
+ const template = this.tagTemplateTarget.content.querySelector('button[data-stimeo--multi-select-target~="remove"]')?.getAttribute("aria-label")?.trim();
10528
+ return template ? fillTemplate(template, { label, value }) : null;
9846
10529
  }
9847
10530
  /** Removes the chip mirroring `option`, if present. */
9848
10531
  #removeTagFor(option) {
@@ -9851,52 +10534,30 @@ var MultiSelectController = class extends Controller {
9851
10534
  tag?.remove();
9852
10535
  }
9853
10536
  /** Removes chip `index` and deselects its option, re-homing focus. */
9854
- #removeTagAt(index) {
10537
+ #removeTagAt(index, focus = "neighbor") {
9855
10538
  const tag = this.tagTargets[index];
9856
10539
  if (!tag) return;
9857
10540
  const value = tag.dataset.value ?? "";
9858
10541
  const option = this.optionTargets.find((candidate) => this.#optionValue(candidate) === value);
9859
10542
  if (option) option.setAttribute("aria-selected", "false");
9860
10543
  tag.remove();
9861
- this.#announce(option ? this.#optionLabel(option) : value);
9862
10544
  this.#refreshRoving();
9863
10545
  this.#syncFields();
9864
- this.dispatch("change", { detail: { values: this.#values } });
9865
- const remaining = this.#removeButtons;
9866
- if (remaining.length === 0) {
9867
- this.#focusAfterLastTag();
9868
- } else {
9869
- this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
10546
+ const values = this.#values;
10547
+ this.#selectionValues = values;
10548
+ this.#announceTransition(
10549
+ false,
10550
+ option ? this.#optionLabel(option) : value,
10551
+ value,
10552
+ values.length
10553
+ );
10554
+ this.dispatch("change", { detail: { values } });
10555
+ if (focus === "input") {
10556
+ this.#focusInput();
10557
+ return;
9870
10558
  }
10559
+ if (!this.#chipRow.focusAfterRemoval(index)) this.#focusAfterLastTag();
9871
10560
  }
9872
- /** Arrow navigation and deletion within the chip list (delegated). */
9873
- #onTagKeydown = (event) => {
9874
- if (event.defaultPrevented) return;
9875
- if (isReservedArrowChord(event)) return;
9876
- const button = event.target.closest("button");
9877
- if (!button) return;
9878
- const buttons = this.#removeButtons;
9879
- const index = buttons.indexOf(button);
9880
- if (index === -1) return;
9881
- switch (logicalArrowKey(event.key, this.element)) {
9882
- case "ArrowLeft":
9883
- if (index > 0) {
9884
- event.preventDefault();
9885
- this.#roving.setActive(index - 1, { focus: true });
9886
- }
9887
- break;
9888
- case "ArrowRight":
9889
- event.preventDefault();
9890
- if (index < buttons.length - 1) this.#roving.setActive(index + 1, { focus: true });
9891
- else this.#focusInput();
9892
- break;
9893
- case "Delete":
9894
- case "Backspace":
9895
- event.preventDefault();
9896
- this.#removeTagAt(index);
9897
- break;
9898
- }
9899
- };
9900
10561
  /**
9901
10562
  * Marks `option` active via `data-active` and the input's
9902
10563
  * `aria-activedescendant` (the attribute is removed, not emptied, when null).
@@ -9927,7 +10588,7 @@ var MultiSelectController = class extends Controller {
9927
10588
  const resolved = this.#activeOption;
9928
10589
  const active = resolved && !resolved.hidden ? resolved : null;
9929
10590
  const marked = this.optionTargets.filter((candidate) => candidate.hasAttribute("data-active"));
9930
- const idref = this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null;
10591
+ const idref = this.inputTarget.getAttribute("aria-activedescendant");
9931
10592
  if (!active || marked.length !== 1 || marked[0] !== active || idref !== activeId) {
9932
10593
  this.#setActive(active);
9933
10594
  }
@@ -9961,12 +10622,19 @@ var MultiSelectController = class extends Controller {
9961
10622
  }
9962
10623
  /** Keeps exactly one chip remove button tabbable after the set changes. */
9963
10624
  #refreshRoving() {
9964
- if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1)
9965
- this.#roving.setActive(0);
10625
+ this.#chipRow.ensureTabStop();
9966
10626
  }
9967
- /** Mirrors the changed option label into the live region. */
9968
- #announce(text) {
9969
- if (this.hasStatusTarget) this.statusTarget.textContent = text;
10627
+ /** Sends one localized selection transition through the page's shared announcer. */
10628
+ #announceTransition(selected, label, value, count) {
10629
+ const template = selected ? this.announceTextValue : this.announceRemovedTextValue;
10630
+ announce(fillTemplate(template, { label, value, count }));
10631
+ }
10632
+ /** Whether two arrays represent the same selection set, independent of DOM order. */
10633
+ #sameSelection(left, right) {
10634
+ if (left.length !== right.length) return false;
10635
+ const a = [...left].sort();
10636
+ const b = [...right].sort();
10637
+ return a.every((value, index) => value === b[index]);
9970
10638
  }
9971
10639
  /** Closes the list on a click outside the controller element. */
9972
10640
  #onOutsideClick = (event) => {
@@ -9986,9 +10654,7 @@ var MultiSelectController = class extends Controller {
9986
10654
  }
9987
10655
  /** Current active target resolved by stable id, never a detached node reference. */
9988
10656
  get #activeOption() {
9989
- const activeId = this.#activeOptionId;
9990
- if (activeId === null) return null;
9991
- return this.optionTargets.find((option) => option.id === activeId) ?? null;
10657
+ return this.optionTargets.find((option) => option.id === this.#activeOptionId) ?? null;
9992
10658
  }
9993
10659
  /** Options currently selected. */
9994
10660
  get #selectedOptions() {
@@ -9998,9 +10664,10 @@ var MultiSelectController = class extends Controller {
9998
10664
  get #values() {
9999
10665
  return this.#selectedOptions.map((option) => this.#optionValue(option));
10000
10666
  }
10001
- /** The chip remove buttons in order (the roving navigation set). */
10002
- get #removeButtons() {
10003
- return this.hasTagsTarget ? Array.from(this.tagsTarget.querySelectorAll("button")) : [];
10667
+ /** Normalized cardinality cap: zero is unlimited and positive fractions round down. */
10668
+ get #selectionLimit() {
10669
+ if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
10670
+ return Math.max(1, Math.floor(this.maxValue));
10004
10671
  }
10005
10672
  /** Whether the list is currently hidden. */
10006
10673
  get #isClosed() {
@@ -10652,6 +11319,7 @@ function finiteFallback(min, max) {
10652
11319
  }
10653
11320
 
10654
11321
  // src/controllers/number_input_controller.ts
11322
+ var OWNED_DISABLED2 = "data-number-input-disabled";
10655
11323
  var NumberInputController = class _NumberInputController extends Controller {
10656
11324
  static targets = ["input", "increment", "decrement"];
10657
11325
  static values = {
@@ -10661,7 +11329,7 @@ var NumberInputController = class _NumberInputController extends Controller {
10661
11329
  pageStep: { type: Number, default: 0 }
10662
11330
  };
10663
11331
  static actions = ["decrement", "increment", "onInput", "onKeydown"];
10664
- static events = ["change"];
11332
+ static events = ["change", "reconcile"];
10665
11333
  /** Delay (ms) a button must be held before auto-repeat starts. */
10666
11334
  static #HOLD_DELAY_MS = 400;
10667
11335
  /** Interval (ms) between auto-repeat steps once a hold has started. */
@@ -10673,8 +11341,18 @@ var NumberInputController = class _NumberInputController extends Controller {
10673
11341
  * of poisoning the next legitimate click.
10674
11342
  */
10675
11343
  static #SUPPRESS_RESET_MS = 250;
10676
- /** Aborts the pointer listeners on disconnect so none outlive the element. */
10677
- #guards = null;
11344
+ /** Aborts global pointer guards on disconnect so none outlive the element. */
11345
+ #globalGuards = null;
11346
+ /** Per-target guards allow an old button to be released without touching its replacement. */
11347
+ #buttonGuards = /* @__PURE__ */ new Map();
11348
+ /** Tracks IME lifecycle on the current input, including confirming keys without a signal. */
11349
+ #composition = new CompositionTracker();
11350
+ /** Restores authored custom-spinbutton ARIA when a target leaves or the controller stops. */
11351
+ #ariaValueNow = new AttributeLease("aria-valuenow");
11352
+ #ariaValueMin = new AttributeLease("aria-valuemin");
11353
+ #ariaValueMax = new AttributeLease("aria-valuemax");
11354
+ /** Last reconciled or user-committed numeric value. */
11355
+ #lastValue = null;
10678
11356
  /** Timers for the hold delay and the suppress-reset safety net. */
10679
11357
  #holdTimeouts = new SafeTimeout();
10680
11358
  /** The running auto-repeat interval (one at a time). */
@@ -10685,63 +11363,120 @@ var NumberInputController = class _NumberInputController extends Controller {
10685
11363
  #repeatedDuringHold = false;
10686
11364
  /** True when the next `click` is the trailing one after a hold and must be ignored. */
10687
11365
  #suppressNextClick = false;
10688
- /** Collapses runtime range/step changes into one silent input reconciliation. */
11366
+ /** Button and pointer that own the current hold, so other instances/pointers cannot end it. */
11367
+ #holdButton = null;
11368
+ #holdPointerId = null;
11369
+ /** Collapses runtime range/step changes into one input reconciliation. */
10689
11370
  #repaint = new MicrotaskCoalescer(() => this.#reconcile());
10690
11371
  /** Normalizes any initial value and wires the focus/hold pointer guards. */
10691
11372
  connect() {
10692
11373
  this.#repaint.activate();
10693
- if (!this.hasInputTarget) return;
11374
+ this.#globalGuards = new AbortController();
11375
+ const { signal } = this.#globalGuards;
11376
+ for (const button of this.incrementTargets) this.#wireButton(button, 1);
11377
+ for (const button of this.decrementTargets) this.#wireButton(button, -1);
11378
+ window.addEventListener("pointerup", this.#onPointerEnd, { signal });
11379
+ window.addEventListener("pointercancel", this.#onPointerEnd, { signal });
11380
+ window.addEventListener("blur", this.#onWindowBlur, { signal });
10694
11381
  this.#reconcile();
10695
- this.#guards = new AbortController();
10696
- const { signal } = this.#guards;
10697
- if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, 1, signal);
10698
- if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -1, signal);
10699
- window.addEventListener("pointerup", this.#stopHold, { signal });
10700
- window.addEventListener("pointercancel", this.#stopHold, { signal });
10701
- window.addEventListener("blur", this.#stopHold, { signal });
10702
- }
10703
- /** Releases the pointer guards and tears down every pending timer and hold state. */
11382
+ }
11383
+ /** Releases listeners, derived attributes, timers, and transient value state. */
10704
11384
  disconnect() {
10705
11385
  this.#repaint.cancel();
10706
- this.#guards?.abort();
10707
- this.#guards = null;
10708
- this.#holdActive = false;
11386
+ this.#composition.disconnect();
11387
+ this.#globalGuards?.abort();
11388
+ this.#globalGuards = null;
11389
+ const buttons = /* @__PURE__ */ new Set([
11390
+ ...this.#buttonGuards.keys(),
11391
+ ...this.incrementTargets,
11392
+ ...this.decrementTargets
11393
+ ]);
11394
+ for (const guard of this.#buttonGuards.values()) guard.abort();
11395
+ this.#buttonGuards.clear();
11396
+ for (const button of buttons) this.#releaseButtonState(button);
11397
+ this.#stopHold(false);
10709
11398
  this.#repeatedDuringHold = false;
10710
11399
  this.#suppressNextClick = false;
10711
11400
  this.#holdTimeouts.clearAll();
10712
11401
  this.#holdIntervals.clearAll();
11402
+ this.#ariaValueNow.returnAll();
11403
+ this.#ariaValueMin.returnAll();
11404
+ this.#ariaValueMax.returnAll();
11405
+ this.#lastValue = null;
10713
11406
  }
10714
- /** Silently reconciles a minimum changed by application code or a Turbo morph. */
11407
+ /** Reconciles the value against a minimum changed by application code or a Turbo morph. */
10715
11408
  minValueChanged() {
10716
11409
  this.#repaint.schedule();
10717
11410
  }
10718
- /** Silently reconciles a maximum changed by application code or a Turbo morph. */
11411
+ /** Reconciles the value against a maximum changed by application code or a Turbo morph. */
10719
11412
  maxValueChanged() {
10720
11413
  this.#repaint.schedule();
10721
11414
  }
10722
- /** Silently reconciles a step changed by application code or a Turbo morph. */
11415
+ /** Reconciles the value against a step changed by application code or a Turbo morph. */
10723
11416
  stepValueChanged() {
10724
11417
  this.#repaint.schedule();
10725
11418
  }
11419
+ /** Tracks and reconciles an input inserted or replaced at runtime. */
11420
+ inputTargetConnected(input) {
11421
+ this.#composition.observe(input);
11422
+ this.#repaint.schedule();
11423
+ }
11424
+ /** Releases ARIA and composition state owned by an input that left the controller. */
11425
+ inputTargetDisconnected(input) {
11426
+ this.#composition.unobserve(input);
11427
+ this.#releaseInputAria(input);
11428
+ this.#stopHold(false);
11429
+ this.#lastValue = null;
11430
+ this.#repaint.schedule();
11431
+ }
11432
+ /** Wires focus preservation and hold behavior on a runtime increment target. */
11433
+ incrementTargetConnected(button) {
11434
+ this.#wireButton(button, 1);
11435
+ this.#repaint.schedule();
11436
+ }
11437
+ /** Releases only the increment target that actually disconnected. */
11438
+ incrementTargetDisconnected(button) {
11439
+ this.#unwireButton(button);
11440
+ this.#repaint.schedule();
11441
+ }
11442
+ /** Wires focus preservation and hold behavior on a runtime decrement target. */
11443
+ decrementTargetConnected(button) {
11444
+ this.#wireButton(button, -1);
11445
+ this.#repaint.schedule();
11446
+ }
11447
+ /** Releases only the decrement target that actually disconnected. */
11448
+ decrementTargetDisconnected(button) {
11449
+ this.#unwireButton(button);
11450
+ this.#repaint.schedule();
11451
+ }
10726
11452
  /** Increases by one step. Bound via `data-action` (click). */
10727
11453
  increment() {
10728
11454
  if (this.#consumeSuppressedClick()) return;
11455
+ if (!this.hasInputTarget) return;
10729
11456
  this.#commitStep(1);
10730
11457
  this.inputTarget.focus();
10731
11458
  }
10732
11459
  /** Decreases by one step. Bound via `data-action` (click). */
10733
11460
  decrement() {
10734
11461
  if (this.#consumeSuppressedClick()) return;
11462
+ if (!this.hasInputTarget) return;
10735
11463
  this.#commitStep(-1);
10736
11464
  this.inputTarget.focus();
10737
11465
  }
10738
11466
  /** Clamps and snaps a typed value. Bound via `data-action` (change). */
10739
11467
  onInput() {
10740
- if (this.inputTarget.value.trim() === "") return;
11468
+ if (!this.hasInputTarget) return;
11469
+ if (this.inputTarget.value.trim() === "") {
11470
+ this.#lastValue = null;
11471
+ this.#reflectEmpty();
11472
+ return;
11473
+ }
10741
11474
  this.#commit(this.#currentValue());
10742
11475
  }
10743
11476
  /** Keyboard stepping per the APG spinbutton model. */
10744
11477
  onKeydown(event) {
11478
+ if (!this.hasInputTarget) return;
11479
+ if (this.#composition.isComposing(event)) return;
10745
11480
  if (isReservedArrowChord(event)) return;
10746
11481
  let next = null;
10747
11482
  switch (event.key) {
@@ -10776,21 +11511,39 @@ var NumberInputController = class _NumberInputController extends Controller {
10776
11511
  * hold; leaving the button while held stops it (the global listeners cover
10777
11512
  * release/cancel/blur).
10778
11513
  */
10779
- #wireButton(button, direction, signal) {
11514
+ #wireButton(button, direction) {
11515
+ if (this.#buttonGuards.has(button)) return;
11516
+ const guard = new AbortController();
11517
+ this.#buttonGuards.set(button, guard);
10780
11518
  button.addEventListener("pointerdown", (event) => this.#armHold(event, button, direction), {
10781
- signal
11519
+ signal: guard.signal
10782
11520
  });
10783
- button.addEventListener("pointerleave", this.#stopHold, { signal });
11521
+ button.addEventListener(
11522
+ "pointerleave",
11523
+ (event) => {
11524
+ if (this.#holdButton === button && this.#ownsPointer(event)) this.#stopHold();
11525
+ },
11526
+ { signal: guard.signal }
11527
+ );
11528
+ }
11529
+ /** Aborts one target's listeners and returns any state derived onto that button. */
11530
+ #unwireButton(button) {
11531
+ this.#buttonGuards.get(button)?.abort();
11532
+ this.#buttonGuards.delete(button);
11533
+ if (this.#holdButton === button) this.#stopHold(false);
11534
+ this.#releaseButtonState(button);
10784
11535
  }
10785
11536
  /** Starts a hold: focus the input, then schedule the first repeat after a delay. */
10786
11537
  #armHold(event, button, direction) {
10787
11538
  const pointerButton = event.button;
10788
11539
  if (typeof pointerButton === "number" && pointerButton !== 0) return;
10789
- if (button.disabled) return;
11540
+ if (button.disabled || !this.hasInputTarget) return;
10790
11541
  event.preventDefault();
10791
11542
  this.inputTarget.focus();
10792
- this.#stopHold();
11543
+ this.#stopHold(false);
10793
11544
  this.#holdActive = true;
11545
+ this.#holdButton = button;
11546
+ this.#holdPointerId = this.#pointerId(event);
10794
11547
  this.#repeatedDuringHold = false;
10795
11548
  this.#suppressNextClick = false;
10796
11549
  this.#holdTimeouts.set(() => {
@@ -10809,18 +11562,39 @@ var NumberInputController = class _NumberInputController extends Controller {
10809
11562
  * trailing synthetic `click` must be ignored, so it is suppressed until the
10810
11563
  * click consumes it or a short safety-net timeout clears it.
10811
11564
  */
10812
- #stopHold = () => {
11565
+ #stopHold(suppressTrailingClick = true) {
10813
11566
  if (!this.#holdActive) return;
10814
11567
  this.#holdActive = false;
11568
+ this.#holdButton = null;
11569
+ this.#holdPointerId = null;
10815
11570
  this.#holdTimeouts.clearAll();
10816
11571
  this.#holdIntervals.clearAll();
10817
- if (this.#repeatedDuringHold) {
11572
+ if (suppressTrailingClick && this.#repeatedDuringHold) {
10818
11573
  this.#suppressNextClick = true;
10819
11574
  this.#holdTimeouts.set(() => {
10820
11575
  this.#suppressNextClick = false;
10821
11576
  }, _NumberInputController.#SUPPRESS_RESET_MS);
11577
+ } else if (!suppressTrailingClick) {
11578
+ this.#suppressNextClick = false;
10822
11579
  }
11580
+ }
11581
+ /** Ends a hold only for its initiating pointer (legacy/synthetic events remain accepted). */
11582
+ #onPointerEnd = (event) => {
11583
+ if (this.#ownsPointer(event)) this.#stopHold();
11584
+ };
11585
+ /** Window deactivation is global and always ends the active hold. */
11586
+ #onWindowBlur = () => {
11587
+ this.#stopHold();
10823
11588
  };
11589
+ /** Extracts pointer identity when the runtime event exposes it. */
11590
+ #pointerId(event) {
11591
+ return event.pointerId ?? null;
11592
+ }
11593
+ /** Whether `event` belongs to the active pointer, with a synthetic-event fallback. */
11594
+ #ownsPointer(event) {
11595
+ const pointerId = this.#pointerId(event);
11596
+ return this.#holdActive && (this.#holdPointerId === null || pointerId === null || pointerId === this.#holdPointerId);
11597
+ }
10824
11598
  /** Consumes a pending trailing-click suppression; returns true if the click was swallowed. */
10825
11599
  #consumeSuppressedClick() {
10826
11600
  if (!this.#suppressNextClick) return false;
@@ -10829,53 +11603,80 @@ var NumberInputController = class _NumberInputController extends Controller {
10829
11603
  return true;
10830
11604
  }
10831
11605
  /**
10832
- * Normalizes `raw`, reflects it, and dispatches `change` only when the
10833
- * displayed value actually changes.
11606
+ * Normalizes `raw`, reflects it, and dispatches `{ value: number }` only when
11607
+ * the committed numeric value differs from the last reconciled value.
10834
11608
  *
10835
11609
  * @returns Whether the value changed (drives the auto-repeat's bound stop).
10836
11610
  */
10837
11611
  #commit(raw) {
10838
11612
  const value = this.#normalize(raw);
10839
- const changed = this.inputTarget.value !== String(value);
11613
+ const changed = this.#lastValue === null || value !== this.#lastValue;
10840
11614
  this.#write(value);
11615
+ this.#lastValue = value;
10841
11616
  if (changed) this.dispatch("change", { detail: { value } });
10842
11617
  return changed;
10843
11618
  }
10844
11619
  /** Commits an adjacent endpoint/grid value and reports whether it moved. */
10845
11620
  #commitStep(count) {
11621
+ if (!this.hasInputTarget) return false;
10846
11622
  return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
10847
11623
  }
10848
11624
  /**
10849
- * Silently reflects the current value after a range or step morph.
11625
+ * Reflects the current value after a range or step morph, reporting a moved
11626
+ * value as `reconcile`.
10850
11627
  *
10851
11628
  * @stimeoRenderRoot
10852
11629
  */
10853
11630
  #reconcile() {
10854
- if (!this.hasInputTarget) return;
11631
+ if (!this.hasInputTarget) {
11632
+ this.#lastValue = null;
11633
+ return;
11634
+ }
10855
11635
  if (this.inputTarget.value.trim() !== "") {
10856
- this.#write(this.#normalize(this.#currentValue()));
11636
+ const previous = this.#lastValue;
11637
+ const value = this.#normalize(this.#currentValue());
11638
+ this.#write(value);
11639
+ this.#lastValue = value;
11640
+ if (previous !== null && value !== previous) {
11641
+ this.dispatch("reconcile", { detail: { value } });
11642
+ }
10857
11643
  } else {
10858
- this.#updateButtons(this.#currentValue());
11644
+ this.#lastValue = null;
11645
+ this.#reflectEmpty();
10859
11646
  }
10860
11647
  }
10861
11648
  /** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
10862
11649
  #write(value) {
10863
- this.inputTarget.value = String(value);
10864
- if (this.inputTarget.getAttribute("role") === "spinbutton") {
10865
- this.inputTarget.setAttribute("aria-valuenow", String(value));
10866
- if (Number.isFinite(this.minValue)) {
10867
- this.inputTarget.setAttribute("aria-valuemin", String(this.minValue));
10868
- }
10869
- if (Number.isFinite(this.maxValue)) {
10870
- this.inputTarget.setAttribute("aria-valuemax", String(this.maxValue));
10871
- }
10872
- }
11650
+ const input = this.inputTarget;
11651
+ input.value = String(value);
11652
+ this.#syncInputAria(input, value);
10873
11653
  this.#updateButtons(value);
10874
11654
  }
11655
+ /** Reflects a blank value without inventing a numeric `aria-valuenow`. */
11656
+ #reflectEmpty() {
11657
+ this.#syncInputAria(this.inputTarget, null);
11658
+ this.#updateButtons(this.#currentValue());
11659
+ }
11660
+ /** Synchronizes controller-derived ARIA while preserving authored teardown values. */
11661
+ #syncInputAria(input, value) {
11662
+ if (input.getAttribute("role") !== "spinbutton") {
11663
+ this.#releaseInputAria(input);
11664
+ return;
11665
+ }
11666
+ this.#ariaValueNow.write(input, value === null ? null : String(value));
11667
+ this.#ariaValueMin.write(input, Number.isFinite(this.minValue) ? String(this.minValue) : null);
11668
+ this.#ariaValueMax.write(input, Number.isFinite(this.maxValue) ? String(this.maxValue) : null);
11669
+ }
11670
+ /** Returns all custom-spinbutton ARIA leased on `input`. */
11671
+ #releaseInputAria(input) {
11672
+ this.#ariaValueNow.return(input);
11673
+ this.#ariaValueMin.return(input);
11674
+ this.#ariaValueMax.return(input);
11675
+ }
10875
11676
  /** Disables a step button at its bound, returning focus to the input first. */
10876
11677
  #updateButtons(value) {
10877
- if (this.hasIncrementTarget) this.#toggleButton(this.incrementTarget, value < this.maxValue);
10878
- if (this.hasDecrementTarget) this.#toggleButton(this.decrementTarget, value > this.minValue);
11678
+ for (const button of this.incrementTargets) this.#toggleButton(button, value < this.maxValue);
11679
+ for (const button of this.decrementTargets) this.#toggleButton(button, value > this.minValue);
10879
11680
  }
10880
11681
  /**
10881
11682
  * Enables or disables a step button at its bound, never disabling one while it
@@ -10885,16 +11686,22 @@ var NumberInputController = class _NumberInputController extends Controller {
10885
11686
  */
10886
11687
  #toggleButton(button, enabled) {
10887
11688
  if (enabled) {
10888
- if (button.hasAttribute("data-number-input-disabled")) {
11689
+ if (button.hasAttribute(OWNED_DISABLED2)) {
10889
11690
  button.disabled = false;
10890
- button.removeAttribute("data-number-input-disabled");
11691
+ button.removeAttribute(OWNED_DISABLED2);
10891
11692
  }
10892
11693
  return;
10893
11694
  }
10894
11695
  if (button.disabled) return;
10895
- if (document.activeElement === button) this.inputTarget.focus();
11696
+ if (document.activeElement === button && this.hasInputTarget) this.inputTarget.focus();
10896
11697
  button.disabled = true;
10897
- button.setAttribute("data-number-input-disabled", "");
11698
+ button.setAttribute(OWNED_DISABLED2, "");
11699
+ }
11700
+ /** Returns `disabled` only when its explicit marker still identifies controller ownership. */
11701
+ #releaseButtonState(button) {
11702
+ if (!button.hasAttribute(OWNED_DISABLED2)) return;
11703
+ button.disabled = false;
11704
+ button.removeAttribute(OWNED_DISABLED2);
10898
11705
  }
10899
11706
  /** The current numeric value, falling back to a finite min (else 0) when blank. */
10900
11707
  #currentValue() {
@@ -12074,6 +12881,7 @@ var PasswordRevealController = class extends Controller {
12074
12881
  }
12075
12882
  };
12076
12883
  var CLASS_PATTERNS = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/];
12884
+ var DEFAULT_LEVELS = ["weak", "fair", "good", "strong"];
12077
12885
  var LENGTH_MILESTONES = [8, 12, 16];
12078
12886
  var MAX_POINTS = LENGTH_MILESTONES.length + (CLASS_PATTERNS.length - 1);
12079
12887
  var STRENGTH_BANDS = ["weak", "fair", "good", "strong"];
@@ -12081,7 +12889,10 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
12081
12889
  static targets = ["input", "meter", "label"];
12082
12890
  static values = {
12083
12891
  minScore: { type: Number, default: 0 },
12084
- levels: { type: Array, default: ["weak", "fair", "good", "strong"] }
12892
+ // A JSON list read through `parseStringList` rather than Stimulus's `Array`
12893
+ // type: that reader throws out of the value observer before any callback
12894
+ // runs, so one malformed attribute would stop the meter connecting.
12895
+ levels: { type: String, default: "" }
12085
12896
  };
12086
12897
  static actions = ["evaluate"];
12087
12898
  static events = ["change"];
@@ -12107,7 +12918,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
12107
12918
  */
12108
12919
  #update(options = {}) {
12109
12920
  const password = this.hasInputTarget ? this.inputTarget.value : "";
12110
- const labels = this.levelsValue;
12921
+ const labels = parseStringList(this.levelsValue, DEFAULT_LEVELS);
12111
12922
  const max = labels.length;
12112
12923
  const score = this.#score(password, max);
12113
12924
  const label = score > 0 ? labels[score - 1] ?? "" : "";
@@ -12187,6 +12998,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
12187
12998
  }
12188
12999
  };
12189
13000
  var NON_VALUE_TYPES = /* @__PURE__ */ new Set(["file", "submit", "reset", "button", "image"]);
13001
+ var DEFAULT_EXCLUDE = ["password"];
12190
13002
  var STORAGE_PREFIX = "stimeo--persist:";
12191
13003
  var OCCURRENCE_SEP = "\0";
12192
13004
  var PersistController = class extends Controller {
@@ -12194,7 +13006,10 @@ var PersistController = class extends Controller {
12194
13006
  static values = {
12195
13007
  key: { type: String, default: "" },
12196
13008
  debounce: { type: Number, default: 400 },
12197
- exclude: { type: Array, default: ["password"] },
13009
+ // A JSON list read through `parseStringList` rather than Stimulus's `Array`
13010
+ // type: that reader throws out of the value observer before any callback
13011
+ // runs, so one malformed attribute would stop the controller connecting.
13012
+ exclude: { type: String, default: "" },
12198
13013
  clearOn: { type: String, default: "" }
12199
13014
  };
12200
13015
  static actions = ["clear"];
@@ -12338,15 +13153,16 @@ var PersistController = class extends Controller {
12338
13153
  /** The fields to persist: `field` targets, or the element's named controls. */
12339
13154
  #fields() {
12340
13155
  const candidates = this.hasFieldTarget ? this.fieldTargets : Array.from(this.element.querySelectorAll("input, textarea, select"));
12341
- return candidates.filter((field) => this.#persistable(field));
13156
+ const excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
13157
+ return candidates.filter((field) => this.#persistable(field, excluded));
12342
13158
  }
12343
13159
  /** Whether a field carries a restorable, non-excluded value. */
12344
- #persistable(field) {
13160
+ #persistable(field, excluded) {
12345
13161
  if (this.#keyOf(field) === null) return false;
12346
13162
  const type = field instanceof HTMLInputElement ? field.type : "";
12347
13163
  if (NON_VALUE_TYPES.has(type)) return false;
12348
- if (this.excludeValue.includes(type)) return false;
12349
- if (field.name.length > 0 && this.excludeValue.includes(field.name)) return false;
13164
+ if (excluded.includes(type)) return false;
13165
+ if (field.name.length > 0 && excluded.includes(field.name)) return false;
12350
13166
  return true;
12351
13167
  }
12352
13168
  /** A stable storage sub-key for a field (its name, else id). */
@@ -13086,85 +13902,525 @@ var ProgressController = class extends Controller {
13086
13902
  this.element.removeAttribute(OWNED_VALUE_TEXT2);
13087
13903
  }
13088
13904
  };
13905
+
13906
+ // src/utils/interactive_host.ts
13907
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
13908
+ function isInteractiveHost(element) {
13909
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
13910
+ let current = element;
13911
+ while (current) {
13912
+ const raw = current.getAttribute("contenteditable");
13913
+ if (raw !== null) {
13914
+ const value = raw.trim().toLowerCase();
13915
+ if (value === "false") return false;
13916
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
13917
+ }
13918
+ current = current.parentElement;
13919
+ }
13920
+ return false;
13921
+ }
13922
+
13923
+ // src/controllers/radio_group_controller.ts
13924
+ var OBSERVED_ATTRIBUTES = [
13925
+ "aria-checked",
13926
+ "aria-disabled",
13927
+ "contenteditable",
13928
+ "controls",
13929
+ "data-value",
13930
+ "disabled",
13931
+ "hidden",
13932
+ "href",
13933
+ "tabindex",
13934
+ "type",
13935
+ "value"
13936
+ ];
13937
+ var hasModifier = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
13089
13938
  var RadioGroupController = class extends Controller {
13090
13939
  static targets = ["radio", "field"];
13091
13940
  static actions = ["onKeydown", "select"];
13092
- static events = ["change"];
13093
- #roving = new RovingTabindex(() => this.radioTargets);
13094
- /** Establishes the roving entry point and reflects the initial selection. */
13941
+ static events = ["change", "reconcile"];
13942
+ #roving = new RovingTabindex(() => this.#managedTargets);
13943
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
13944
+ #handledEvents = /* @__PURE__ */ new WeakSet();
13945
+ #managedRadios = /* @__PURE__ */ new Set();
13946
+ #originalTabindex = /* @__PURE__ */ new Map();
13947
+ #ownedChecked = /* @__PURE__ */ new Set();
13948
+ #internalCheckedValues = /* @__PURE__ */ new Map();
13949
+ #internalTabindexValues = /* @__PURE__ */ new Map();
13950
+ #observer = null;
13951
+ #committedRadio = null;
13952
+ #connected = false;
13953
+ #lastOrder = [];
13954
+ #focusedRadio = null;
13955
+ #pendingFocusIndex = null;
13956
+ #preferChecked = false;
13957
+ /**
13958
+ * Normalizes authored selection, establishes the APG Tab entry point, and
13959
+ * reflects derived form state without reporting a user edit.
13960
+ */
13095
13961
  connect() {
13096
- const selected = this.#selectedIndex();
13097
- this.#roving.setActive(selected === -1 ? 0 : selected);
13098
- if (selected !== -1) this.#reflectField(this.radioTargets[selected], { silent: true });
13962
+ const currentRadios = new Set(this.radioTargets);
13963
+ for (const radio of this.#managedRadios) {
13964
+ if (!currentRadios.has(radio)) this.#releaseRadio(radio);
13965
+ }
13966
+ for (const radio of this.radioTargets) this.#reconcileHost(radio, false);
13967
+ this.#normalizeSelection();
13968
+ this.#ensureTabStop(true);
13969
+ this.#reflectField(this.#selectedRadio, { silent: true });
13970
+ this.#committedRadio = this.#selectedRadio ?? null;
13971
+ this.#lastOrder = this.#managedTargets;
13972
+ this.element.addEventListener("click", this.#onClickCapture, true);
13973
+ this.element.addEventListener("keydown", this.#onKeydownCapture, true);
13974
+ this.element.addEventListener("click", this.#onClick);
13975
+ this.element.addEventListener("keydown", this.#onKeydown);
13976
+ this.element.addEventListener("focusin", this.#onFocusin);
13977
+ this.element.addEventListener("focusout", this.#onFocusout);
13978
+ this.#connected = true;
13979
+ this.#preferChecked = false;
13980
+ this.#internalCheckedValues.clear();
13981
+ this.#internalTabindexValues.clear();
13982
+ this.#reconcile.activate();
13983
+ this.#observeMutations();
13099
13984
  }
13100
- /** Selects the clicked radio. Bound via `data-action` (click). */
13101
- select(event) {
13102
- const index = this.radioTargets.indexOf(event.currentTarget);
13103
- if (index !== -1) this.#selectIndex(index, { focus: false });
13985
+ /** Releases every listener, observer, and queued pass while retaining live DOM state. */
13986
+ disconnect() {
13987
+ this.#connected = false;
13988
+ this.#reconcile.cancel();
13989
+ this.element.removeEventListener("click", this.#onClickCapture, true);
13990
+ this.element.removeEventListener("keydown", this.#onKeydownCapture, true);
13991
+ this.element.removeEventListener("click", this.#onClick);
13992
+ this.element.removeEventListener("keydown", this.#onKeydown);
13993
+ this.element.removeEventListener("focusin", this.#onFocusin);
13994
+ this.element.removeEventListener("focusout", this.#onFocusout);
13995
+ this.#observer?.disconnect();
13996
+ this.#observer = null;
13997
+ this.#internalCheckedValues.clear();
13998
+ this.#internalTabindexValues.clear();
13999
+ this.#focusedRadio = null;
14000
+ this.#pendingFocusIndex = null;
14001
+ this.#committedRadio = null;
14002
+ this.#preferChecked = false;
14003
+ }
14004
+ /** Removes a newly connected radio from the Tab sequence before batch reconciliation. */
14005
+ radioTargetConnected(radio) {
14006
+ if (this.#connected === false) return;
14007
+ this.#reconcileHost(radio, true);
14008
+ this.#preferChecked = true;
14009
+ this.#reconcile.schedule();
13104
14010
  }
13105
- /** Arrow/Home/End/Space navigation with selection-follows-focus. */
13106
- onKeydown(event) {
13107
- if (event.defaultPrevented) return;
13108
- if (isReservedArrowChord(event)) return;
13109
- const current = this.radioTargets.indexOf(event.currentTarget);
13110
- if (current === -1) return;
13111
- let next = null;
13112
- const step = logicalArrowStep(event.key, this.element);
13113
- switch (event.key) {
13114
- case "ArrowDown":
13115
- case "ArrowRight":
13116
- case "ArrowUp":
13117
- case "ArrowLeft":
13118
- next = rovingMove(current, this.radioTargets.length, step, "wrap");
13119
- break;
13120
- case "Home":
13121
- next = 0;
13122
- break;
13123
- case "End":
13124
- next = this.radioTargets.length - 1;
13125
- break;
13126
- case " ":
13127
- next = current;
13128
- break;
13129
- default:
13130
- return;
14011
+ /** Releases target-only attributes and repairs selection, form state, focus, and roving. */
14012
+ radioTargetDisconnected(radio) {
14013
+ if (!this.#connected) return;
14014
+ if (this.#focusedRadio === radio) {
14015
+ this.#pendingFocusIndex = this.#lastOrder.indexOf(radio);
14016
+ this.#focusedRadio = null;
13131
14017
  }
13132
- event.preventDefault();
13133
- this.#selectIndex(next, { focus: true });
14018
+ this.#releaseRadio(radio);
14019
+ this.#preferChecked = true;
14020
+ this.#reconcile.schedule();
13134
14021
  }
13135
- /**
13136
- * Checks the radio at `index`, clears the rest, updates the roving Tab stop and
13137
- * the hidden field, and dispatches `change`.
13138
- */
13139
- #selectIndex(index, { focus }) {
13140
- const radio = this.radioTargets[index];
13141
- if (!radio) return;
13142
- this.radioTargets.forEach((item, i) => {
13143
- item.setAttribute("aria-checked", i === index ? "true" : "false");
13144
- });
13145
- this.#roving.setActive(index, { focus });
13146
- this.#reflectField(radio);
13147
- this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
14022
+ /** Reflects the current selection into a field added or replaced at runtime. */
14023
+ fieldTargetConnected() {
14024
+ this.#reconcile.schedule();
13148
14025
  }
13149
- /** Index of the currently checked radio, or `-1` if none. */
13150
- #selectedIndex() {
13151
- return this.radioTargets.findIndex((radio) => radio.getAttribute("aria-checked") === "true");
14026
+ /** Reconciles after a field target is removed from a retained group. */
14027
+ fieldTargetDisconnected() {
14028
+ this.#reconcile.schedule();
13152
14029
  }
13153
- /**
13154
- * Mirrors a radio's `data-value` onto the hidden field, when present. Fires a
13155
- * native bubbling `change` on a real value change (matching `listbox`, so
13156
- * `auto-submit` and other native-`change` consumers react) — unless `silent`
13157
- * (the initial connect reflection, which is not a user edit).
13158
- */
13159
- #reflectField(radio, { silent = false } = {}) {
13160
- if (!this.hasFieldTarget || !radio) return;
13161
- const value = this.#radioValue(radio);
13162
- if (this.fieldTarget.value === value) return;
13163
- this.fieldTarget.value = value;
13164
- if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
14030
+ /** Selects the action's radio. Per-radio action wiring is optional. */
14031
+ select(event) {
14032
+ if (event.defaultPrevented || !this.#ownsEventTarget(event.target)) return;
14033
+ const radio = event.currentTarget;
14034
+ if (!radio || !this.radioTargets.includes(radio)) return;
14035
+ this.#handledEvents.add(event);
14036
+ this.#selectRadio(radio, { focus: false });
13165
14037
  }
13166
- /** A radio's submitted value (`data-value`, defaulting to empty). */
13167
- #radioValue(radio) {
14038
+ /** Applies the APG key map to the action's radio. Per-radio action wiring is optional. */
14039
+ onKeydown(event) {
14040
+ const radio = event.currentTarget;
14041
+ if (!radio || !this.radioTargets.includes(radio)) return;
14042
+ this.#handleKeydown(event, radio);
14043
+ }
14044
+ /** Blocks disabled pointer activation before per-radio and consumer handlers run. */
14045
+ #onClickCapture = (event) => {
14046
+ const radio = this.#radioForEventTarget(event.target);
14047
+ if (!radio || !this.#isSupportedHost(radio) || !this.#isActivationDisabled(radio)) return;
14048
+ event.preventDefault();
14049
+ event.stopImmediatePropagation();
14050
+ };
14051
+ /** Blocks disabled activation and suppresses a button's non-APG Enter activation. */
14052
+ #onKeydownCapture = (event) => {
14053
+ if (event.key !== " " && event.key !== "Enter") return;
14054
+ const radio = this.#radioForEventTarget(event.target);
14055
+ if (!radio || !this.#isSupportedHost(radio)) return;
14056
+ if (this.#isActivationDisabled(radio) || event.key === "Enter" && radio instanceof HTMLButtonElement) {
14057
+ event.preventDefault();
14058
+ event.stopImmediatePropagation();
14059
+ }
14060
+ };
14061
+ /** Delegated click path for static and runtime-added radios without actions. */
14062
+ #onClick = (event) => {
14063
+ if (this.#handledEvents.delete(event) || event.defaultPrevented) return;
14064
+ const radio = this.#radioForEventTarget(event.target);
14065
+ if (radio) this.#selectRadio(radio, { focus: false });
14066
+ };
14067
+ /** Delegated keydown path for static and runtime-added radios without actions. */
14068
+ #onKeydown = (event) => {
14069
+ const radio = this.#radioForEventTarget(event.target);
14070
+ if (radio) this.#handleKeydown(event, radio);
14071
+ };
14072
+ /** Keeps programmatic and pointer focus as the group's current roving position. */
14073
+ #onFocusin = (event) => {
14074
+ const radio = this.#radioForEventTarget(event.target);
14075
+ if (!radio || !this.#isNavigable(radio)) return;
14076
+ this.#focusedRadio = radio;
14077
+ this.#setActive(radio);
14078
+ };
14079
+ /** Distinguishes ordinary focus departure from focus lost because its radio was removed. */
14080
+ #onFocusout = (event) => {
14081
+ const radio = this.#radioForEventTarget(event.target);
14082
+ if (!radio) return;
14083
+ queueMicrotask(() => {
14084
+ if (!this.#connected || this.#focusedRadio !== radio || !radio.isConnected) return;
14085
+ const active = document.activeElement;
14086
+ if (!(active instanceof Node) || !radio.contains(active)) this.#focusedRadio = null;
14087
+ });
14088
+ };
14089
+ /** Applies navigation and Space activation to one supported radio. */
14090
+ #handleKeydown(event, radio) {
14091
+ if (event.defaultPrevented || !this.#isSupportedHost(radio)) return;
14092
+ if (event.isComposing || isReservedArrowChord(event)) return;
14093
+ if ((event.key === "Home" || event.key === "End") && hasModifier(event)) return;
14094
+ if (event.key === " ") {
14095
+ if (this.#isActivationDisabled(radio)) {
14096
+ event.preventDefault();
14097
+ event.stopImmediatePropagation();
14098
+ return;
14099
+ }
14100
+ if (radio instanceof HTMLButtonElement) {
14101
+ if (event.repeat) event.preventDefault();
14102
+ return;
14103
+ }
14104
+ event.preventDefault();
14105
+ if (!event.repeat) this.#selectRadio(radio, { focus: true });
14106
+ return;
14107
+ }
14108
+ const items = this.#managedTargets;
14109
+ const current = items.indexOf(radio);
14110
+ const step = logicalArrowStep(event.key, this.element);
14111
+ let destination;
14112
+ if (step !== 0) {
14113
+ destination = this.#nextNavigable(current, step);
14114
+ } else if (event.key === "Home") {
14115
+ destination = this.#navigableRadios[0];
14116
+ } else if (event.key === "End") {
14117
+ const navigable = this.#navigableRadios;
14118
+ destination = navigable[navigable.length - 1];
14119
+ } else {
14120
+ return;
14121
+ }
14122
+ event.preventDefault();
14123
+ if (!destination) return;
14124
+ if (this.#isActivationDisabled(destination)) this.#setActive(destination, true);
14125
+ else this.#selectRadio(destination, { focus: true });
14126
+ }
14127
+ /** Applies one user selection and emits only when selected identity changes. */
14128
+ #selectRadio(radio, { focus }) {
14129
+ if (!this.#isSupportedHost(radio) || this.#isActivationDisabled(radio)) return;
14130
+ const previous = this.#selectedRadio;
14131
+ const changed = previous !== radio;
14132
+ if (changed) {
14133
+ for (const item of this.#managedTargets) this.#setChecked(item, item === radio);
14134
+ }
14135
+ this.#setActive(radio, focus);
14136
+ this.#reflectField(radio, { silent: !changed });
14137
+ this.#committedRadio = radio;
14138
+ if (changed) {
14139
+ this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
14140
+ }
14141
+ }
14142
+ /** Reconciles target ownership, selection, roving, focus continuity, and form state. */
14143
+ #reconcileDom() {
14144
+ const observer = this.#observer;
14145
+ observer?.disconnect();
14146
+ for (const radio of this.radioTargets) this.#reconcileHost(radio, true);
14147
+ this.#normalizeSelection();
14148
+ const pendingFocusIndex = this.#pendingFocusIndex;
14149
+ this.#pendingFocusIndex = null;
14150
+ if (pendingFocusIndex !== null) {
14151
+ const destination = this.#nearestNavigable(pendingFocusIndex);
14152
+ this.#setActive(destination, destination !== null);
14153
+ } else {
14154
+ this.#ensureTabStop(this.#preferChecked);
14155
+ }
14156
+ this.#reflectField(this.#selectedRadio, { silent: true });
14157
+ this.#lastOrder = this.#managedTargets;
14158
+ this.#preferChecked = false;
14159
+ this.#internalCheckedValues.clear();
14160
+ this.#internalTabindexValues.clear();
14161
+ if (this.#connected && observer) this.#observeWith(observer);
14162
+ this.#reportReconciledSelection();
14163
+ }
14164
+ /**
14165
+ * Announces a selection this pass decided rather than the user. Dispatched after
14166
+ * observation resumes so a consumer's own DOM edits are seen by the next pass.
14167
+ */
14168
+ #reportReconciledSelection() {
14169
+ const settled = this.#selectedRadio ?? null;
14170
+ if (settled === this.#committedRadio) return;
14171
+ this.#committedRadio = settled;
14172
+ this.dispatch("reconcile", {
14173
+ detail: { value: settled ? this.#radioValue(settled) : "", radio: settled }
14174
+ });
14175
+ }
14176
+ /** Begins or ends ownership according to a radio's current host semantics. */
14177
+ #reconcileHost(radio, dropFromTabSequence) {
14178
+ if (!this.#isSupportedHost(radio)) {
14179
+ this.#releaseRadio(radio);
14180
+ return;
14181
+ }
14182
+ if (!this.#managedRadios.has(radio)) {
14183
+ this.#managedRadios.add(radio);
14184
+ this.#originalTabindex.set(radio, radio.getAttribute("tabindex"));
14185
+ if (dropFromTabSequence) this.#writeTabindex(radio, -1);
14186
+ }
14187
+ this.#normalizeChecked(radio);
14188
+ }
14189
+ /** Restores only defaults owned while an element belonged to this group. */
14190
+ #releaseRadio(radio) {
14191
+ this.#managedRadios.delete(radio);
14192
+ const original = this.#originalTabindex.get(radio);
14193
+ if (original === null) {
14194
+ this.#markInternal(this.#internalTabindexValues, radio, null);
14195
+ radio.removeAttribute("tabindex");
14196
+ } else if (original !== void 0) {
14197
+ this.#markInternal(this.#internalTabindexValues, radio, original);
14198
+ radio.setAttribute("tabindex", original);
14199
+ }
14200
+ this.#originalTabindex.delete(radio);
14201
+ if (this.#ownedChecked.delete(radio)) {
14202
+ this.#markInternal(this.#internalCheckedValues, radio, null);
14203
+ radio.removeAttribute("aria-checked");
14204
+ }
14205
+ }
14206
+ /** Supplies a missing checked state and normalizes invalid ARIA tokens. */
14207
+ #normalizeChecked(radio) {
14208
+ const value = radio.getAttribute("aria-checked");
14209
+ if (value === null) {
14210
+ this.#ownedChecked.add(radio);
14211
+ this.#setChecked(radio, false);
14212
+ } else if (value !== "true" && value !== "false") {
14213
+ this.#setChecked(radio, false);
14214
+ }
14215
+ }
14216
+ /** Makes the first DOM-ordered checked radio the sole checked radio. */
14217
+ #normalizeSelection() {
14218
+ let found = false;
14219
+ for (const radio of this.#managedTargets) {
14220
+ this.#normalizeChecked(radio);
14221
+ if (!this.#isChecked(radio)) continue;
14222
+ if (!found) found = true;
14223
+ else this.#setChecked(radio, false);
14224
+ }
14225
+ }
14226
+ /** Keeps one navigable Tab stop, preferring checked state when selection changed externally. */
14227
+ #ensureTabStop(preferChecked) {
14228
+ const navigable = this.#navigableRadios;
14229
+ let active;
14230
+ if (!preferChecked) {
14231
+ const current = this.#managedTargets.filter(
14232
+ (radio) => radio.tabIndex === 0 && this.#isNavigable(radio)
14233
+ );
14234
+ if (current.length === 1) active = current[0];
14235
+ }
14236
+ const selected = this.#selectedRadio;
14237
+ if (!active && selected && this.#isNavigable(selected)) active = selected;
14238
+ active ??= navigable[0];
14239
+ this.#setActive(active ?? null);
14240
+ }
14241
+ /**
14242
+ * The surviving navigable radio closest to a position in the order captured
14243
+ * before the removal: the first one at or after it, else the last one before it.
14244
+ * The saved position and this search read the same population, so a `hidden` or
14245
+ * natively disabled sibling never shifts the destination past its neighbor.
14246
+ */
14247
+ #nearestNavigable(index) {
14248
+ const navigable = new Set(this.#navigableRadios);
14249
+ for (let i = index; i < this.#lastOrder.length; i++) {
14250
+ const radio = this.#lastOrder[i];
14251
+ if (radio && navigable.has(radio)) return radio;
14252
+ }
14253
+ for (let i = index - 1; i >= 0; i--) {
14254
+ const radio = this.#lastOrder[i];
14255
+ if (radio && navigable.has(radio)) return radio;
14256
+ }
14257
+ return this.#navigableRadios[0] ?? null;
14258
+ }
14259
+ /** Moves through full DOM order until a navigable radio is found. */
14260
+ #nextNavigable(fromIndex, delta) {
14261
+ const items = this.#managedTargets;
14262
+ let index = fromIndex;
14263
+ for (let step = 0; step < items.length; step++) {
14264
+ index = rovingMove(index, items.length, delta, "wrap");
14265
+ const candidate = items[index];
14266
+ if (candidate && this.#isNavigable(candidate)) return candidate;
14267
+ }
14268
+ return void 0;
14269
+ }
14270
+ /** Assigns the single roving Tab stop and optionally moves DOM focus. */
14271
+ #setActive(radio, focus = false) {
14272
+ const items = this.#managedTargets;
14273
+ const index = items.indexOf(radio);
14274
+ this.#roving.setActive(index, { focus });
14275
+ for (const item of items) {
14276
+ this.#markInternal(this.#internalTabindexValues, item, item.getAttribute("tabindex"));
14277
+ }
14278
+ }
14279
+ /** Finds this group's radio containing an event target, excluding nested groups. */
14280
+ #radioForEventTarget(target) {
14281
+ if (!this.#ownsEventTarget(target)) return void 0;
14282
+ const node = target;
14283
+ return this.radioTargets.find((radio) => radio === node || radio.contains(node));
14284
+ }
14285
+ /** Whether the closest Radio Group scope around a target is this instance. */
14286
+ #ownsEventTarget(target) {
14287
+ return target instanceof Element && target.closest('[data-controller~="stimeo--radio-group"]') === this.element;
14288
+ }
14289
+ /** Hosts whose activation model can be owned without conflicting native behavior. */
14290
+ #isSupportedHost(radio) {
14291
+ if (radio instanceof HTMLButtonElement) return radio.type === "button";
14292
+ return !isInteractiveHost(radio);
14293
+ }
14294
+ /** Radios eligible for roving focus; ARIA-disabled deliberately remains eligible. */
14295
+ #isNavigable(radio) {
14296
+ if (!this.#isSupportedHost(radio) || this.#isHidden(radio)) return false;
14297
+ if (!(radio instanceof HTMLButtonElement)) return true;
14298
+ return !radio.disabled && !inheritsFieldsetDisabled(radio);
14299
+ }
14300
+ /** Whether hidden applies on the path from a radio up to the group root. */
14301
+ #isHidden(radio) {
14302
+ let current = radio;
14303
+ while (current && current !== this.element) {
14304
+ if (current.hasAttribute("hidden")) return true;
14305
+ current = current.parentElement;
14306
+ }
14307
+ return false;
14308
+ }
14309
+ /** Whether ARIA, visibility, or native HTML semantics suppress selection. */
14310
+ #isActivationDisabled(radio) {
14311
+ if (!this.#isNavigable(radio)) return true;
14312
+ let current = radio;
14313
+ while (current) {
14314
+ if (current.getAttribute("aria-disabled") === "true") return true;
14315
+ current = current.parentElement;
14316
+ }
14317
+ return false;
14318
+ }
14319
+ /** Distinguishes external retained-element changes from this controller's own writes. */
14320
+ #handleMutationRecords(records) {
14321
+ let external = false;
14322
+ for (const record of records) {
14323
+ const target = record.target;
14324
+ const attribute = record.attributeName;
14325
+ if (attribute === "aria-checked") {
14326
+ const value = target.getAttribute(attribute);
14327
+ if (this.#matchesInternal(this.#internalCheckedValues, target, value)) continue;
14328
+ this.#ownedChecked.delete(target);
14329
+ this.#preferChecked = true;
14330
+ external = true;
14331
+ } else if (attribute === "tabindex") {
14332
+ const value = target.getAttribute(attribute);
14333
+ if (this.#matchesInternal(this.#internalTabindexValues, target, value)) continue;
14334
+ if (this.#managedRadios.has(target)) this.#originalTabindex.set(target, value);
14335
+ external = true;
14336
+ } else {
14337
+ external = true;
14338
+ }
14339
+ }
14340
+ this.#internalCheckedValues.clear();
14341
+ this.#internalTabindexValues.clear();
14342
+ if (external) this.#reconcile.schedule();
14343
+ }
14344
+ /** Records an attribute write this pass made, keeping one claim per record it will produce. */
14345
+ #markInternal(values, target, value) {
14346
+ const pending = values.get(target);
14347
+ if (pending) {
14348
+ pending.value = value;
14349
+ pending.count += 1;
14350
+ } else {
14351
+ values.set(target, { value, count: 1 });
14352
+ }
14353
+ }
14354
+ /** Consumes one claim on a record whose value this pass wrote. */
14355
+ #matchesInternal(values, target, value) {
14356
+ const pending = values.get(target);
14357
+ if (!pending || pending.value !== value) return false;
14358
+ pending.count -= 1;
14359
+ if (pending.count === 0) values.delete(target);
14360
+ return true;
14361
+ }
14362
+ /** Watches target membership, retained state/hosts, form state, and fieldset ancestry. */
14363
+ #observeMutations() {
14364
+ const observer = new MutationObserver((records) => this.#handleMutationRecords(records));
14365
+ this.#observer = observer;
14366
+ this.#observeWith(observer);
14367
+ }
14368
+ /** Registers root and ancestor observation on one observer instance. */
14369
+ #observeWith(observer) {
14370
+ observer.observe(this.element, {
14371
+ subtree: true,
14372
+ childList: true,
14373
+ attributes: true,
14374
+ attributeFilter: OBSERVED_ATTRIBUTES
14375
+ });
14376
+ let ancestor = this.element.parentElement;
14377
+ while (ancestor) {
14378
+ observer.observe(ancestor, {
14379
+ attributes: true,
14380
+ attributeFilter: ["contenteditable", "disabled"]
14381
+ });
14382
+ ancestor = ancestor.parentElement;
14383
+ }
14384
+ }
14385
+ /** Supported targets currently managed by the roving primitive. */
14386
+ get #managedTargets() {
14387
+ return this.radioTargets.filter((radio) => this.#managedRadios.has(radio));
14388
+ }
14389
+ /** Managed radios eligible for the roving Tab stop. */
14390
+ get #navigableRadios() {
14391
+ return this.#managedTargets.filter((radio) => this.#isNavigable(radio));
14392
+ }
14393
+ /** The currently checked managed radio, if any. */
14394
+ get #selectedRadio() {
14395
+ return this.#managedTargets.find((radio) => this.#isChecked(radio));
14396
+ }
14397
+ /** Whether a radio is currently checked. */
14398
+ #isChecked(radio) {
14399
+ return radio.getAttribute("aria-checked") === "true";
14400
+ }
14401
+ /** Reflects checked state while distinguishing controller writes from authored morphs. */
14402
+ #setChecked(radio, checked) {
14403
+ const value = checked ? "true" : "false";
14404
+ if (radio.getAttribute("aria-checked") === value) return;
14405
+ this.#markInternal(this.#internalCheckedValues, radio, value);
14406
+ radio.setAttribute("aria-checked", value);
14407
+ }
14408
+ /** Writes a roving value before the radio is visible to the shared primitive. */
14409
+ #writeTabindex(radio, value) {
14410
+ const serialized = String(value);
14411
+ this.#markInternal(this.#internalTabindexValues, radio, serialized);
14412
+ radio.tabIndex = value;
14413
+ }
14414
+ /** Mirrors the selected value or the empty state to the optional hidden field. */
14415
+ #reflectField(radio, { silent = false } = {}) {
14416
+ if (!this.hasFieldTarget) return;
14417
+ const value = radio ? this.#radioValue(radio) : "";
14418
+ if (this.fieldTarget.value === value) return;
14419
+ this.fieldTarget.value = value;
14420
+ if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
14421
+ }
14422
+ /** A radio's submitted value (`data-value`, defaulting to empty). */
14423
+ #radioValue(radio) {
13168
14424
  return radio.getAttribute("data-value") ?? "";
13169
14425
  }
13170
14426
  };
@@ -13542,162 +14798,246 @@ var RatingController = class extends Controller {
13542
14798
  static targets = ["symbol", "field"];
13543
14799
  static values = {
13544
14800
  value: { type: Number, default: 0 },
13545
- max: { type: Number, default: 5 },
13546
14801
  clearable: { type: Boolean, default: true },
13547
14802
  readonly: { type: Boolean, default: false }
13548
14803
  };
13549
14804
  static actions = ["endPreview", "onKeydown", "preview", "select"];
13550
- static events = ["change"];
14805
+ static events = ["change", "reconcile"];
13551
14806
  #roving = new RovingTabindex(() => this.symbolTargets);
13552
- /** Reflects the initial value, or switches to the non-interactive readonly view. */
13553
- /**
13554
- * Collapses a morph that swaps render inputs into one repaint, and refuses the
13555
- * pass Stimulus delivers before `connect()`.
13556
- */
13557
- #repaint = new MicrotaskCoalescer(() => {
13558
- if (this.readonlyValue) {
13559
- this.#applyReadonly();
13560
- return;
13561
- }
13562
- this.#apply(this.#clamp(this.valueValue), { focus: false });
13563
- });
14807
+ #rootRole = new AttributeLease("role");
14808
+ #symbolRole = new AttributeLease("role");
14809
+ #symbolAriaHidden = new AttributeLease("aria-hidden");
14810
+ #rootTabindex = new TabindexLoan();
14811
+ #repaint = new MicrotaskCoalescer(() => this.#reconcileScale());
14812
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
14813
+ #connected = false;
14814
+ #rescuedFocus = false;
14815
+ /** Reflects declarative state without announcing an initial user change. */
13564
14816
  connect() {
13565
14817
  this.#repaint.activate();
13566
- if (this.readonlyValue) {
13567
- this.#applyReadonly();
13568
- return;
13569
- }
13570
- this.#apply(this.#clamp(this.valueValue), { focus: false });
14818
+ this.#beforeCache.activate();
14819
+ this.#apply(this.#normalize(this.valueValue), { focus: false });
14820
+ this.#connected = true;
13571
14821
  }
13572
- /** Closes the window in which a queued repaint may still run. */
14822
+ /** Drops a queued reconciliation and hands every borrowed attribute back. */
13573
14823
  disconnect() {
14824
+ this.#connected = false;
13574
14825
  this.#repaint.cancel();
14826
+ this.#beforeCache.deactivate();
14827
+ this.#releaseReadonly();
13575
14828
  }
13576
- /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
14829
+ /** Removes a runtime-added symbol's authored Tab stop before the batch repaint. */
14830
+ symbolTargetConnected(symbol) {
14831
+ if (this.#connected === false) return;
14832
+ symbol.tabIndex = -1;
14833
+ this.#repaint.schedule();
14834
+ }
14835
+ /** Releases readonly ownership and reconciles the remaining DOM-ordered scale. */
14836
+ symbolTargetDisconnected(symbol) {
14837
+ this.#symbolRole.return(symbol);
14838
+ this.#symbolAriaHidden.return(symbol);
14839
+ this.#repaint.schedule();
14840
+ }
14841
+ /** Reflects into a hidden field added or replaced after connection. */
14842
+ fieldTargetConnected() {
14843
+ this.#repaint.schedule();
14844
+ }
14845
+ /** Reconciles after a hidden field is removed or replaced. */
14846
+ fieldTargetDisconnected() {
14847
+ this.#repaint.schedule();
14848
+ }
14849
+ /** Repaints when application code or a Turbo morph changes `value`. */
13577
14850
  valueValueChanged() {
13578
14851
  this.#repaint.schedule();
13579
14852
  }
13580
- /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
14853
+ /** Repaints when application code changes whether value 0 is permitted. */
14854
+ clearableValueChanged() {
14855
+ this.#repaint.schedule();
14856
+ }
14857
+ /** Repaints when application code enters or leaves the readonly snapshot. */
14858
+ readonlyValueChanged() {
14859
+ this.#repaint.schedule();
14860
+ }
14861
+ /** Selects or clears the clicked symbol. Bound via `data-action` (click). */
13581
14862
  select(event) {
13582
14863
  if (this.readonlyValue) return;
13583
- const value = this.#clamp(this.#symbolValue(event.currentTarget));
13584
- if (this.clearableValue && value === this.valueValue) {
13585
- this.#render(0, { focus: true });
13586
- } else {
13587
- this.#render(value, { focus: false });
13588
- }
14864
+ const ordinal = this.#symbolOrdinal(event.currentTarget);
14865
+ if (ordinal === null) return;
14866
+ const current = this.#normalize(this.valueValue);
14867
+ this.#commit(this.clearableValue && ordinal === current ? 0 : ordinal, {
14868
+ focus: ordinal === current
14869
+ });
13589
14870
  }
13590
- /** Previews a fill range on hover/focus. Bound via `data-action` (mouseenter/focus). */
14871
+ /** Previews a fill range on hover or focus without committing it. */
13591
14872
  preview(event) {
13592
14873
  if (this.readonlyValue) return;
13593
- this.#setFillRange(this.#clamp(this.#symbolValue(event.currentTarget)));
14874
+ const ordinal = this.#symbolOrdinal(event.currentTarget);
14875
+ if (ordinal !== null) this.#setFillRange(ordinal);
13594
14876
  }
13595
- /** Restores the fill range to the selected value. Bound via `data-action` (mouseleave/blur). */
14877
+ /** Restores the fill range after hover or focus leaves a symbol. */
13596
14878
  endPreview() {
13597
14879
  if (this.readonlyValue) return;
13598
- this.#setFillRange(this.valueValue);
14880
+ this.#setFillRange(this.#normalize(this.valueValue));
13599
14881
  }
13600
- /** Arrow/Home/End/Space keyboard control, clamped (no wrap). */
14882
+ /** Arrow/Home/End/Space/Delete keyboard control, clamped without wrapping. */
13601
14883
  onKeydown(event) {
13602
- if (event.defaultPrevented) return;
13603
- if (isReservedArrowChord(event)) return;
13604
- if (this.readonlyValue) return;
14884
+ if (event.defaultPrevented || isReservedArrowChord(event) || this.readonlyValue) return;
14885
+ const current = this.#normalize(this.valueValue);
13605
14886
  let next = null;
13606
14887
  const rtl = isRtl(this.element);
13607
14888
  switch (event.key) {
13608
14889
  case "ArrowRight":
13609
14890
  case "ArrowUp":
13610
- next = this.valueValue + (event.key === "ArrowRight" && rtl ? -1 : 1);
14891
+ next = current + (event.key === "ArrowRight" && rtl ? -1 : 1);
13611
14892
  break;
13612
14893
  case "ArrowLeft":
13613
14894
  case "ArrowDown":
13614
- next = this.valueValue - (event.key === "ArrowLeft" && rtl ? -1 : 1);
14895
+ next = current - (event.key === "ArrowLeft" && rtl ? -1 : 1);
13615
14896
  break;
13616
14897
  case "Home":
13617
14898
  next = this.#minValue;
13618
14899
  break;
13619
14900
  case "End":
13620
- next = this.maxValue;
14901
+ next = this.symbolTargets.length;
13621
14902
  break;
13622
14903
  case " ":
13623
14904
  case "Enter":
13624
- next = this.#symbolValue(event.currentTarget);
14905
+ next = this.#symbolOrdinal(event.currentTarget);
14906
+ break;
14907
+ case "Delete":
14908
+ case "Backspace":
14909
+ if (!this.clearableValue) return;
14910
+ next = 0;
13625
14911
  break;
13626
14912
  default:
13627
14913
  return;
13628
14914
  }
14915
+ if (next === null) return;
13629
14916
  event.preventDefault();
13630
- this.#render(this.#clamp(next), { focus: true });
14917
+ this.#commit(next, { focus: true });
13631
14918
  }
13632
14919
  /**
13633
- * Applies `value` (already clamped) everywhere, then dispatches `change`.
13634
- * Use for user-driven changes; on connect call `#apply` directly so
13635
- * initialization mirrors state without emitting an event.
14920
+ * Repaints one settled target/Value mutation batch and reports only a value
14921
+ * this controller had to normalize.
13636
14922
  *
13637
14923
  * @stimeoRenderRoot
13638
14924
  */
13639
- #render(value, { focus }) {
14925
+ #reconcileScale() {
14926
+ const requested = this.valueValue;
14927
+ const value = this.#normalize(requested);
14928
+ this.#apply(value, { focus: false });
14929
+ if (!Object.is(value, requested)) {
14930
+ this.dispatch("reconcile", { detail: { value } });
14931
+ }
14932
+ }
14933
+ /** Applies one user operation and emits only when its committed value changes. */
14934
+ #commit(raw, { focus }) {
14935
+ const previous = this.#normalize(this.valueValue);
14936
+ const value = this.#normalize(raw);
13640
14937
  this.#apply(value, { focus });
13641
- this.dispatch("change", { detail: { value } });
14938
+ if (value !== previous) this.dispatch("change", { detail: { value } });
13642
14939
  }
13643
- /**
13644
- * Stores `value`, syncs `aria-checked` and the roving Tab stop, the hidden
13645
- * field, and the fill range — without dispatching `change`. Idempotent and
13646
- * safe on connect (and across Turbo morphing).
13647
- */
14940
+ /** Synchronizes value, ARIA, roving focus, form state, and the visual fill hook. */
13648
14941
  #apply(value, { focus }) {
13649
- this.valueValue = value;
13650
- this.symbolTargets.forEach((symbol) => {
13651
- symbol.setAttribute(
13652
- "aria-checked",
13653
- value > 0 && this.#symbolValue(symbol) === value ? "true" : "false"
13654
- );
14942
+ if (!Object.is(this.valueValue, value)) this.valueValue = value;
14943
+ this.symbolTargets.forEach((symbol, index) => {
14944
+ symbol.setAttribute("aria-checked", value > 0 && index + 1 === value ? "true" : "false");
13655
14945
  });
13656
- this.#roving.setActive(value > 0 ? value - 1 : 0, { focus });
14946
+ if (this.readonlyValue) {
14947
+ this.#applyReadonly();
14948
+ } else {
14949
+ const returning = this.#releaseReadonly();
14950
+ this.#roving.setActive(value > 0 ? value - 1 : 0, { focus: focus || returning });
14951
+ }
13657
14952
  if (this.hasFieldTarget) this.fieldTarget.value = String(value);
13658
14953
  this.#setFillRange(value);
13659
14954
  }
13660
- /** Marks symbols up to `range` with `data-rating-hover` (the consumer's fill hook). */
14955
+ /** Marks the first `range` symbols with the consumer-owned fill hook. */
13661
14956
  #setFillRange(range) {
13662
- for (const symbol of this.symbolTargets) {
13663
- if (this.#symbolValue(symbol) <= range && range > 0) {
13664
- symbol.setAttribute("data-rating-hover", "");
13665
- } else {
13666
- symbol.removeAttribute("data-rating-hover");
13667
- }
13668
- }
14957
+ this.symbolTargets.forEach((symbol, index) => {
14958
+ symbol.toggleAttribute("data-rating-hover", range > 0 && index < range);
14959
+ });
13669
14960
  }
13670
- /** Turns the group into a non-interactive `role="img"` snapshot of the value. */
14961
+ /**
14962
+ * Temporarily turns the radiogroup into a non-interactive image snapshot.
14963
+ *
14964
+ * Each lease is returned before it is taken again, so a value the consumer
14965
+ * wrote while readonly becomes the value the lease restores on release. The
14966
+ * return is a no-op on an attribute still holding this controller's own
14967
+ * write, which is the ordinary case.
14968
+ */
13671
14969
  #applyReadonly() {
13672
- const value = this.#clamp(this.valueValue);
13673
- this.valueValue = value;
13674
- this.element.setAttribute("role", "img");
14970
+ this.#rescueFocus();
14971
+ this.#rootRole.return(this.element);
14972
+ this.#rootRole.write(this.element, "img");
13675
14973
  for (const symbol of this.symbolTargets) {
13676
- symbol.removeAttribute("role");
13677
- symbol.setAttribute("aria-hidden", "true");
13678
- symbol.tabIndex = -1;
14974
+ this.#symbolRole.return(symbol);
14975
+ this.#symbolRole.write(symbol, null);
14976
+ this.#symbolAriaHidden.return(symbol);
14977
+ this.#symbolAriaHidden.write(symbol, "true");
13679
14978
  }
13680
- if (this.hasFieldTarget) this.fieldTarget.value = String(value);
13681
- this.#setFillRange(value);
14979
+ this.#roving.setActive(-1);
13682
14980
  }
13683
14981
  /**
13684
- * Clamps `value` to `[min, max]` (min is 0 when clearable, else 1). The upper
13685
- * bound is also capped at the number of symbols so the roving Tab stop
13686
- * (`value - 1`) always maps to a real symbol — even if the consumer's `max`
13687
- * value and rendered symbol count disagree, the tabbable item is never lost.
14982
+ * Hands every readonly borrowing back before Turbo clones the page.
14983
+ *
14984
+ * The snapshot is taken while the controller is still connected, so an
14985
+ * element left as `role="img"` with hidden symbols is what a restored page
14986
+ * connects against — and that markup would be read as the authored one,
14987
+ * leaving no way back to the radiogroup. Rewinding first keeps the cached
14988
+ * copy identical to what the consumer wrote.
13688
14989
  */
13689
- #clamp(value) {
13690
- const max = Math.min(this.maxValue, this.symbolTargets.length);
13691
- return Math.min(max, Math.max(this.#minValue, value));
14990
+ #rewindForCache() {
14991
+ this.#releaseReadonly();
14992
+ const value = this.#normalize(this.valueValue);
14993
+ this.#roving.setActive(value > 0 ? value - 1 : 0);
14994
+ }
14995
+ /**
14996
+ * Lands focus on the root before the symbols leave the accessibility tree.
14997
+ *
14998
+ * A symbol holding focus when readonly begins would keep it while losing its
14999
+ * role and gaining `aria-hidden`, stranding the user on a node no longer in
15000
+ * the tree. The root is the one element that survives the transition named:
15001
+ * it carries the consumer's accessible name under `role="img"`.
15002
+ */
15003
+ #rescueFocus() {
15004
+ const active = document.activeElement;
15005
+ if (!(active instanceof HTMLElement) || active === this.element) return;
15006
+ if (!this.element.contains(active)) return;
15007
+ this.#rootTabindex.lend(this.element);
15008
+ this.element.focus();
15009
+ this.#rescuedFocus = true;
15010
+ }
15011
+ /**
15012
+ * Restores authored roles and visibility after leaving readonly mode.
15013
+ *
15014
+ * @returns whether focus is standing on the root because {@link #rescueFocus}
15015
+ * put it there, and therefore belongs back on the Tab stop.
15016
+ */
15017
+ #releaseReadonly() {
15018
+ const returning = this.#rescuedFocus && document.activeElement === this.element;
15019
+ this.#rescuedFocus = false;
15020
+ this.#rootRole.return(this.element);
15021
+ this.#symbolRole.returnAll();
15022
+ this.#symbolAriaHidden.returnAll();
15023
+ this.#rootTabindex.returnAll();
15024
+ return returning;
15025
+ }
15026
+ /** Normalizes a raw value to an integer ordinal in the live DOM range. */
15027
+ #normalize(raw) {
15028
+ const maximum = this.symbolTargets.length;
15029
+ const ordinal = Number.isFinite(raw) ? Math.round(raw) : this.#minValue;
15030
+ return Math.min(maximum, Math.max(this.#minValue, ordinal));
13692
15031
  }
13693
15032
  /** Lowest selectable value: 0 when clearable, otherwise 1. */
13694
15033
  get #minValue() {
13695
15034
  return this.clearableValue ? 0 : 1;
13696
15035
  }
13697
- /** A symbol's ordinal value (`data-rating-value`, defaulting to its position). */
13698
- #symbolValue(symbol) {
13699
- const raw = Number(symbol.getAttribute("data-rating-value"));
13700
- return Number.isFinite(raw) && raw > 0 ? raw : this.symbolTargets.indexOf(symbol) + 1;
15036
+ /** Returns a target's 1-based position, or null when the action host is invalid. */
15037
+ #symbolOrdinal(target) {
15038
+ const targets = this.symbolTargets;
15039
+ const index = targets.indexOf(target);
15040
+ return index < 0 ? null : index + 1;
13701
15041
  }
13702
15042
  };
13703
15043
  var ReadMoreController = class extends Controller {
@@ -14237,16 +15577,31 @@ var RovingController = class extends Controller {
14237
15577
  };
14238
15578
  static events = ["change"];
14239
15579
  #roving = new RovingTabindex(() => this.itemTargets);
15580
+ #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
15581
+ #connected = false;
14240
15582
  connect() {
14241
- const active = this.#roving.activeIndex;
14242
- this.#roving.setActive(active === -1 ? 0 : active);
15583
+ this.#ensureTabStop();
14243
15584
  this.element.addEventListener("keydown", this.#onKeydown);
14244
15585
  this.element.addEventListener("focusin", this.#onFocusin);
15586
+ this.#connected = true;
15587
+ this.#reconcile.activate();
14245
15588
  }
14246
15589
  disconnect() {
15590
+ this.#connected = false;
15591
+ this.#reconcile.cancel();
14247
15592
  this.element.removeEventListener("keydown", this.#onKeydown);
14248
15593
  this.element.removeEventListener("focusin", this.#onFocusin);
14249
15594
  }
15595
+ /** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
15596
+ itemTargetConnected(item) {
15597
+ if (!this.#connected) return;
15598
+ item.tabIndex = -1;
15599
+ this.#reconcile.schedule();
15600
+ }
15601
+ /** Re-establishes the single Tab stop after an item leaves the target set. */
15602
+ itemTargetDisconnected() {
15603
+ this.#reconcile.schedule();
15604
+ }
14250
15605
  /** Arrow keys move focus + the tab stop; Home/End jump to the ends. */
14251
15606
  #onKeydown = (event) => {
14252
15607
  if (event.defaultPrevented) return;
@@ -14300,6 +15655,11 @@ var RovingController = class extends Controller {
14300
15655
  this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
14301
15656
  }
14302
15657
  }
15658
+ /** Keeps the first existing Tab stop, falling back to the first live item. */
15659
+ #ensureTabStop() {
15660
+ const active = this.#roving.activeIndex;
15661
+ this.#roving.setActive(active === -1 ? 0 : active);
15662
+ }
14303
15663
  };
14304
15664
  var FOCUSABLE_SELECTOR = [
14305
15665
  "a[href]",
@@ -14318,29 +15678,56 @@ var ScrollAreaController = class extends Controller {
14318
15678
  };
14319
15679
  static events = ["reach"];
14320
15680
  #layout = new LayoutObserver(() => this.#update());
15681
+ /** Current element receiving scroll, resize, mutation, and keyboard-reach behavior. */
15682
+ #viewport = null;
15683
+ /** Guards target callbacks before connect and after disconnect. */
15684
+ #connected = false;
15685
+ /** Collapses target replacement callbacks into one final-DOM rebind. */
15686
+ #rebind = new MicrotaskCoalescer(() => this.#syncViewport());
14321
15687
  /** Re-checks the tab stop when the viewport's focusable content comes or goes. */
14322
15688
  #content = null;
14323
15689
  /** Last edge reported via `reach`, so the event fires once per arrival. */
14324
15690
  #lastEdge = null;
14325
15691
  /** Whether this controller added `tabindex`, so teardown only removes its own. */
14326
15692
  #tabindex = new TabindexLoan("0");
14327
- /** Whether this controller added `role="region"`, for symmetric teardown. */
14328
- #addedRole = false;
15693
+ /** Temporarily owns a derived `role="region"` without losing an authored replacement. */
15694
+ #role = new AttributeLease("role");
14329
15695
  #onScroll = () => {
14330
15696
  this.#update();
14331
15697
  };
14332
15698
  connect() {
14333
- if (!this.hasViewportTarget) return;
14334
- this.viewportTarget.addEventListener("scroll", this.#onScroll, { passive: true });
14335
- this.#layout.observe(this.viewportTarget);
15699
+ this.#connected = true;
15700
+ this.#rebind.activate();
14336
15701
  this.#layout.observeViewport();
15702
+ this.#syncViewport();
15703
+ }
15704
+ /** Schedules a complete observer/listener rebind for a runtime viewport target. */
15705
+ viewportTargetConnected() {
15706
+ this.#rebind.schedule();
15707
+ }
15708
+ /** Schedules cleanup or replacement binding after a viewport leaves. */
15709
+ viewportTargetDisconnected() {
15710
+ this.#rebind.schedule();
15711
+ }
15712
+ /** Rebinds every viewport-owned resource against the final target in this mutation batch. */
15713
+ #syncViewport() {
15714
+ if (!this.#connected) return;
15715
+ const next = this.hasViewportTarget ? this.viewportTarget : null;
15716
+ if (next === this.#viewport) {
15717
+ this.#update();
15718
+ return;
15719
+ }
15720
+ if (this.#viewport) this.#unbindViewport(this.#viewport);
15721
+ this.#viewport = next;
15722
+ if (!next) return;
15723
+ next.addEventListener("scroll", this.#onScroll, { passive: true });
15724
+ this.#layout.observe(next);
14337
15725
  if (typeof MutationObserver !== "undefined") {
14338
15726
  this.#content = new MutationObserver(() => {
14339
- if (!this.hasViewportTarget) return;
14340
- const vp = this.viewportTarget;
14341
- this.#syncKeyboardReach(vp, this.#syncOverflow(vp));
15727
+ if (this.#viewport !== next) return;
15728
+ this.#syncKeyboardReach(next, this.#syncOverflow(next));
14342
15729
  });
14343
- this.#content.observe(this.viewportTarget, {
15730
+ this.#content.observe(next, {
14344
15731
  subtree: true,
14345
15732
  childList: true,
14346
15733
  attributes: true
@@ -14349,19 +15736,26 @@ var ScrollAreaController = class extends Controller {
14349
15736
  this.#update();
14350
15737
  }
14351
15738
  disconnect() {
14352
- if (this.hasViewportTarget) {
14353
- this.viewportTarget.removeEventListener("scroll", this.#onScroll);
14354
- this.#clearAddedAttributes(this.viewportTarget);
14355
- }
15739
+ this.#connected = false;
15740
+ this.#rebind.cancel();
15741
+ if (this.#viewport) this.#unbindViewport(this.#viewport);
14356
15742
  this.#layout.disconnect();
15743
+ this.#lastEdge = null;
15744
+ }
15745
+ /** Releases every resource and derived attribute owned by one former viewport. */
15746
+ #unbindViewport(viewport) {
15747
+ viewport.removeEventListener("scroll", this.#onScroll);
15748
+ this.#layout.unobserve(viewport);
14357
15749
  this.#content?.disconnect();
14358
15750
  this.#content = null;
15751
+ this.#clearAddedAttributes(viewport);
15752
+ if (this.#viewport === viewport) this.#viewport = null;
14359
15753
  this.#lastEdge = null;
14360
15754
  }
14361
15755
  /** Re-measures overflow and scroll position and reflects the state hooks. */
14362
15756
  #update() {
14363
- if (!this.hasViewportTarget) return;
14364
- const vp = this.viewportTarget;
15757
+ const vp = this.#viewport;
15758
+ if (!vp) return;
14365
15759
  const overflowing = this.#syncOverflow(vp);
14366
15760
  this.#syncKeyboardReach(vp, overflowing);
14367
15761
  const { position, progress } = this.#measurePosition(vp);
@@ -14375,7 +15769,6 @@ var ScrollAreaController = class extends Controller {
14375
15769
  this.#lastEdge = null;
14376
15770
  }
14377
15771
  }
14378
- /** Whether the viewport can scroll on the configured axis. */
14379
15772
  /**
14380
15773
  * Measures overflow and reflects the `data-overflow` hook.
14381
15774
  *
@@ -14420,8 +15813,7 @@ var ScrollAreaController = class extends Controller {
14420
15813
  if (wantsTabindex) {
14421
15814
  this.#tabindex.lend(vp);
14422
15815
  if (!vp.hasAttribute("role") && this.#hasAccessibleName(vp)) {
14423
- vp.setAttribute("role", "region");
14424
- this.#addedRole = true;
15816
+ this.#role.write(vp, "region");
14425
15817
  }
14426
15818
  } else {
14427
15819
  this.#clearAddedAttributes(vp);
@@ -14430,10 +15822,7 @@ var ScrollAreaController = class extends Controller {
14430
15822
  /** Removes (and resets the flags for) only the attributes this controller added. */
14431
15823
  #clearAddedAttributes(vp) {
14432
15824
  this.#tabindex.returnAll();
14433
- if (this.#addedRole) {
14434
- vp.removeAttribute("role");
14435
- this.#addedRole = false;
14436
- }
15825
+ this.#role.return(vp);
14437
15826
  }
14438
15827
  /**
14439
15828
  * Whether the viewport owns something the user can Tab to *right now*.
@@ -16666,26 +18055,7 @@ var SubmitOnceController = class extends Controller {
16666
18055
  }
16667
18056
  }
16668
18057
  };
16669
-
16670
- // src/utils/interactive_host.ts
16671
- var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
16672
- function isInteractiveHost(element) {
16673
- if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
16674
- let current = element;
16675
- while (current) {
16676
- const raw = current.getAttribute("contenteditable");
16677
- if (raw !== null) {
16678
- const value = raw.trim().toLowerCase();
16679
- if (value === "false") return false;
16680
- if (value === "" || value === "true" || value === "plaintext-only") return true;
16681
- }
16682
- current = current.parentElement;
16683
- }
16684
- return false;
16685
- }
16686
-
16687
- // src/controllers/switch_controller.ts
16688
- var OBSERVED_ATTRIBUTES = [
18058
+ var OBSERVED_ATTRIBUTES2 = [
16689
18059
  "role",
16690
18060
  "aria-checked",
16691
18061
  "tabindex",
@@ -16798,7 +18168,7 @@ var SwitchController = class extends Controller {
16798
18168
  #observeAttributes() {
16799
18169
  this.#attributeObserver.observe(this.element, {
16800
18170
  attributes: true,
16801
- attributeFilter: OBSERVED_ATTRIBUTES
18171
+ attributeFilter: OBSERVED_ATTRIBUTES2
16802
18172
  });
16803
18173
  let ancestor = this.element.parentElement;
16804
18174
  while (ancestor) {
@@ -16903,30 +18273,47 @@ var TabsController = class extends Controller {
16903
18273
  }
16904
18274
  };
16905
18275
  var TagsInputController = class extends Controller {
16906
- static targets = ["input", "tags", "tag", "tagTemplate", "status", "fields"];
18276
+ static targets = ["input", "tags", "tag", "tagTemplate", "label", "remove", "fields"];
16907
18277
  static values = {
16908
18278
  delimiter: { type: String, default: "," },
16909
18279
  max: { type: Number, default: 0 },
16910
18280
  allowDuplicates: { type: Boolean, default: false },
16911
- name: { type: String, default: "tags[]" }
18281
+ name: { type: String, default: "tags[]" },
18282
+ announceText: { type: String, default: "" },
18283
+ announceRemovedText: { type: String, default: "" }
16912
18284
  };
16913
18285
  static actions = ["onKeydown"];
16914
- static events = ["change", "reject"];
16915
- #roving = new RovingTabindex(() => this.#removeButtons);
18286
+ static events = ["change", "reconcile", "reject"];
18287
+ /** Last reconciled tag order, separating user edits from DOM/Turbo repair. */
18288
+ #tagValues = [];
18289
+ /** Whether this connection already reported its unusable chip template. */
18290
+ #warnedTemplate = false;
18291
+ /** Collapses one target/Value mutation batch into one final-DOM repair pass. */
18292
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileTags());
18293
+ #chipRow = new ChipRow({
18294
+ directionElement: this.element,
18295
+ getItems: () => this.tagTargets,
18296
+ getButton: (tag) => tag.querySelector('button[data-stimeo--tags-input-target~="remove"]'),
18297
+ onRemove: (index) => this.#removeAt(index),
18298
+ focusAfterEnd: () => this.#focusInput()
18299
+ });
16916
18300
  /** Owns IME lifecycle state for commit-key guarding. */
16917
18301
  #composition = new CompositionTracker();
16918
18302
  /** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
16919
18303
  connect() {
18304
+ this.#warnedTemplate = false;
16920
18305
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
16921
- this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
16922
- this.tagsTarget.addEventListener("click", this.#onTagClick);
16923
- this.#syncState();
18306
+ if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
18307
+ const tags = this.#values;
18308
+ this.#syncState(tags);
18309
+ this.#tagValues = tags;
18310
+ this.#reconcile.activate();
16924
18311
  }
16925
18312
  /** Releases the delegated listeners so no handler outlives the element. */
16926
18313
  disconnect() {
18314
+ this.#reconcile.cancel();
16927
18315
  this.#composition.disconnect();
16928
- this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
16929
- this.tagsTarget.removeEventListener("click", this.#onTagClick);
18316
+ this.#chipRow.disconnect();
16930
18317
  }
16931
18318
  /** Tracks an input added initially or after connect. */
16932
18319
  inputTargetConnected(input) {
@@ -16936,10 +18323,40 @@ var TagsInputController = class extends Controller {
16936
18323
  inputTargetDisconnected(input) {
16937
18324
  this.#composition.unobserve(input);
16938
18325
  }
18326
+ /** Rebinds delegation and derived state when the tags container is replaced. */
18327
+ tagsTargetConnected(_tags) {
18328
+ this.#reconcile.schedule();
18329
+ }
18330
+ /** Releases only the row that disconnected, leaving a newer target intact. */
18331
+ tagsTargetDisconnected(tags) {
18332
+ this.#chipRow.disconnect(tags);
18333
+ this.#reconcile.schedule();
18334
+ }
18335
+ /** Reconciles a tag inserted by Turbo, a server render, or another controller. */
18336
+ tagTargetConnected(_tag) {
18337
+ this.#reconcile.schedule();
18338
+ }
18339
+ /** Reconciles a tag removed by Turbo, a server render, or another controller. */
18340
+ tagTargetDisconnected(_tag) {
18341
+ this.#reconcile.schedule();
18342
+ }
18343
+ /** Seeds a fields target inserted after connect. */
18344
+ fieldsTargetConnected() {
18345
+ this.#reconcile.schedule();
18346
+ }
18347
+ /** Rebuilds submitted fields when the public name changes at runtime. */
18348
+ nameValueChanged() {
18349
+ this.#reconcile.schedule();
18350
+ }
18351
+ /** Recomputes the full hook when the cap changes at runtime. */
18352
+ maxValueChanged() {
18353
+ this.#reconcile.schedule();
18354
+ }
16939
18355
  /** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
16940
18356
  onKeydown(event) {
16941
18357
  if (event.defaultPrevented) return;
16942
18358
  if (isReservedArrowChord(event)) return;
18359
+ if (!this.hasInputTarget) return;
16943
18360
  if (this.#composition.isComposing(event)) return;
16944
18361
  if (event.key === "Enter" || event.key === this.delimiterValue) {
16945
18362
  event.preventDefault();
@@ -16947,33 +18364,20 @@ var TagsInputController = class extends Controller {
16947
18364
  return;
16948
18365
  }
16949
18366
  if (event.key === "Backspace" && this.inputTarget.value === "") {
16950
- const buttons = this.#removeButtons;
16951
- if (buttons.length > 0) {
18367
+ const last = this.#chipRow.lastIndex;
18368
+ if (last >= 0) {
16952
18369
  event.preventDefault();
16953
- this.#removeAt(buttons.length - 1);
18370
+ this.#removeAt(last, "input");
16954
18371
  }
16955
18372
  return;
16956
18373
  }
16957
18374
  if (logicalArrowKey(event.key, this.element) === "ArrowLeft" && this.inputTarget.value === "") {
16958
- const buttons = this.#removeButtons;
16959
- if (buttons.length > 0) {
18375
+ if (this.#chipRow.length > 0) {
16960
18376
  event.preventDefault();
16961
- this.#roving.setActive(buttons.length - 1, { focus: true });
18377
+ this.#chipRow.focusLast();
16962
18378
  }
16963
18379
  }
16964
18380
  }
16965
- /**
16966
- * Deletes the tag whose remove button was clicked. Delegated on the tags
16967
- * container (like `#onTagKeydown`) rather than bound per chip via
16968
- * `data-action`, so it works the instant a chip is appended without waiting on
16969
- * Stimulus to wire a freshly created element.
16970
- */
16971
- #onTagClick = (event) => {
16972
- const button = event.target.closest("button");
16973
- if (!button || !this.tagsTarget.contains(button)) return;
16974
- const index = this.#removeButtons.indexOf(button);
16975
- if (index !== -1) this.#removeAt(index);
16976
- };
16977
18381
  /** Validates and adds the current input value as a tag, then clears the input. */
16978
18382
  #commitInput() {
16979
18383
  const value = this.inputTarget.value.trim();
@@ -16989,78 +18393,83 @@ var TagsInputController = class extends Controller {
16989
18393
  this.#reject(value, "duplicate");
16990
18394
  return;
16991
18395
  }
16992
- this.#appendTag(value);
18396
+ if (!this.#appendTag(value)) return;
16993
18397
  this.inputTarget.value = "";
16994
- this.#announce(value);
16995
- this.#syncState();
16996
- this.dispatch("change", { detail: { tags: this.#values } });
18398
+ const tags = this.#values;
18399
+ this.#syncState(tags);
18400
+ this.#tagValues = tags;
18401
+ this.#announceTransition(true, value, tags.length);
18402
+ this.dispatch("change", { detail: { tags } });
16997
18403
  }
16998
18404
  /** Builds one chip from the template and appends it to the tag list. */
16999
18405
  #appendTag(value) {
17000
- if (!this.hasTagTemplateTarget) return;
18406
+ if (!this.hasTagsTarget) return false;
18407
+ if (!this.hasTagTemplateTarget) return this.#warnTemplate('a "tagTemplate" target');
17001
18408
  const fragment = this.tagTemplateTarget.content.cloneNode(true);
17002
- const tag = fragment.querySelector('[data-stimeo--tags-input-target="tag"]');
17003
- const label = fragment.querySelector('[data-tags-input-slot="label"]');
17004
- const button = fragment.querySelector("button");
17005
- if (!tag || !button) return;
18409
+ const tag = fragment.querySelector('[data-stimeo--tags-input-target~="tag"]');
18410
+ const label = fragment.querySelector('[data-stimeo--tags-input-target~="label"]');
18411
+ const button = fragment.querySelector(
18412
+ 'button[data-stimeo--tags-input-target~="remove"]'
18413
+ );
18414
+ const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
18415
+ if (!tag) return this.#warnTemplate('a "tag" target');
18416
+ if (!label) return this.#warnTemplate('a "label" target');
18417
+ if (!button) return this.#warnTemplate('a "remove" target <button>');
18418
+ if (removeName === "") {
18419
+ return this.#warnTemplate('a non-empty aria-label on its "remove" target');
18420
+ }
17006
18421
  tag.dataset.value = value;
17007
- if (label) label.textContent = value;
17008
- button.setAttribute("aria-label", `Remove ${value}`);
18422
+ label.textContent = value;
18423
+ button.setAttribute("aria-label", fillTemplate(removeName, { label: value, value }));
17009
18424
  button.tabIndex = -1;
17010
18425
  this.tagsTarget.appendChild(fragment);
18426
+ return true;
17011
18427
  }
17012
- /** Removes the tag at `index`, then moves focus to a neighbor or the input. */
17013
- #removeAt(index) {
18428
+ /**
18429
+ * Reports an unusable chip template to the author, once per connection.
18430
+ *
18431
+ * The commit itself stays a no-op — nothing about the input, the tag set, the
18432
+ * hidden fields, the announcement, or the events changes. Without this line
18433
+ * the only symptom is a field that accepts no tags at all, and the two causes
18434
+ * the Inspector cannot see statically (a name that renders empty from a
18435
+ * missing translation, a server-rendered template) would have no diagnostic
18436
+ * anywhere.
18437
+ */
18438
+ #warnTemplate(missing) {
18439
+ if (!this.#warnedTemplate) {
18440
+ this.#warnedTemplate = true;
18441
+ console.warn(
18442
+ `Stimeo UI: "${this.identifier}" added no tag because its chip template lacks ${missing}.`
18443
+ );
18444
+ }
18445
+ return false;
18446
+ }
18447
+ /** Removes the tag at `index`, then applies the interaction-origin focus policy. */
18448
+ #removeAt(index, focus = "neighbor") {
17014
18449
  const tag = this.tagTargets[index];
17015
18450
  if (!tag) return;
17016
18451
  const value = tag.dataset.value ?? "";
17017
18452
  tag.remove();
17018
- this.#announce(value);
17019
- this.#syncState();
17020
- this.dispatch("change", { detail: { tags: this.#values } });
17021
- const remaining = this.#removeButtons;
17022
- if (remaining.length === 0) {
17023
- this.inputTarget.focus();
17024
- } else {
17025
- this.#roving.setActive(Math.min(index, remaining.length - 1), { focus: true });
18453
+ const tags = this.#values;
18454
+ this.#syncState(tags);
18455
+ this.#tagValues = tags;
18456
+ this.#announceTransition(false, value, tags.length);
18457
+ this.dispatch("change", { detail: { tags } });
18458
+ if (focus === "input") {
18459
+ this.#focusInput();
18460
+ return;
17026
18461
  }
18462
+ if (!this.#chipRow.focusAfterRemoval(index)) this.#focusInput();
18463
+ }
18464
+ /** Moves focus to the current input when one exists. */
18465
+ #focusInput() {
18466
+ if (this.hasInputTarget) this.inputTarget.focus();
17027
18467
  }
17028
- /** Handles arrow navigation and deletion within the chip list (delegated). */
17029
- #onTagKeydown = (event) => {
17030
- if (event.defaultPrevented) return;
17031
- if (isReservedArrowChord(event)) return;
17032
- const button = event.target.closest("button");
17033
- if (!button) return;
17034
- const buttons = this.#removeButtons;
17035
- const index = buttons.indexOf(button);
17036
- if (index === -1) return;
17037
- switch (logicalArrowKey(event.key, this.element)) {
17038
- case "ArrowLeft":
17039
- if (index > 0) {
17040
- event.preventDefault();
17041
- this.#roving.setActive(index - 1, { focus: true });
17042
- }
17043
- break;
17044
- case "ArrowRight":
17045
- event.preventDefault();
17046
- if (index < buttons.length - 1) {
17047
- this.#roving.setActive(index + 1, { focus: true });
17048
- } else {
17049
- this.inputTarget.focus();
17050
- }
17051
- break;
17052
- case "Delete":
17053
- case "Backspace":
17054
- event.preventDefault();
17055
- this.#removeAt(index);
17056
- break;
17057
- }
17058
- };
17059
18468
  /** Rebuilds the hidden form fields, the `full` flag, and the roving Tab stop. */
17060
- #syncState() {
18469
+ #syncState(values) {
17061
18470
  if (this.hasFieldsTarget) {
17062
18471
  this.fieldsTarget.replaceChildren(
17063
- ...this.#values.map((value) => {
18472
+ ...values.map((value) => {
17064
18473
  const input = document.createElement("input");
17065
18474
  input.type = "hidden";
17066
18475
  input.name = this.nameValue;
@@ -17069,28 +18478,37 @@ var TagsInputController = class extends Controller {
17069
18478
  })
17070
18479
  );
17071
18480
  }
17072
- const full = this.maxValue > 0 && this.tagTargets.length >= this.maxValue;
18481
+ const full = this.maxValue > 0 && values.length >= this.maxValue;
17073
18482
  this.element.toggleAttribute("data-stimeo--tags-input-full", full);
17074
- if (this.#removeButtons.length > 0 && this.#roving.activeIndex === -1) {
17075
- this.#roving.setActive(0);
17076
- }
17077
- }
17078
- /** Mirrors the changed tag into the live region for assistive tech. */
17079
- #announce(value) {
17080
- if (this.hasStatusTarget) this.statusTarget.textContent = value;
17081
- }
17082
- /** Reports a rejected addition via `stimeo--tags-input:reject`. */
18483
+ this.#chipRow.ensureTabStop();
18484
+ }
18485
+ /** Repairs derived state after DOM/Turbo changes and reports a changed tag order. */
18486
+ #reconcileTags() {
18487
+ if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
18488
+ else this.#chipRow.disconnect();
18489
+ const tags = this.#values;
18490
+ this.#syncState(tags);
18491
+ const changed = !this.#sameTags(this.#tagValues, tags);
18492
+ this.#tagValues = tags;
18493
+ if (changed) this.dispatch("reconcile", { detail: { tags } });
18494
+ }
18495
+ /** Sends one localized tag transition through the page's shared announcer. */
18496
+ #announceTransition(added, value, count) {
18497
+ const template = added ? this.announceTextValue : this.announceRemovedTextValue;
18498
+ announce(fillTemplate(template, { label: value, value, count }));
18499
+ }
18500
+ /** Reports rejection with `{ value, reason: "duplicate" | "empty" | "max" }`. */
17083
18501
  #reject(value, reason) {
17084
18502
  this.dispatch("reject", { detail: { value, reason } });
17085
18503
  }
17086
- /** The remove buttons in document order (the roving navigation set). */
17087
- get #removeButtons() {
17088
- return Array.from(this.tagsTarget.querySelectorAll("button"));
17089
- }
17090
18504
  /** Current tag values in order. */
17091
18505
  get #values() {
17092
18506
  return this.tagTargets.map((tag) => tag.dataset.value ?? "");
17093
18507
  }
18508
+ /** Whether two arrays carry the same values in the same submitted order. */
18509
+ #sameTags(left, right) {
18510
+ return left.length === right.length && left.every((value, index) => value === right[index]);
18511
+ }
17094
18512
  };
17095
18513
  function px(value) {
17096
18514
  const n = Number.parseFloat(value);
@@ -17300,6 +18718,10 @@ var ThemeController = class extends Controller {
17300
18718
  };
17301
18719
  var AM = 0;
17302
18720
  var PM = 1;
18721
+ var SECONDS_PER_MINUTE = 60;
18722
+ var SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
18723
+ var SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
18724
+ var hasModifier2 = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
17303
18725
  var TimePickerController = class extends Controller {
17304
18726
  static targets = ["segment", "field"];
17305
18727
  static values = {
@@ -17309,35 +18731,80 @@ var TimePickerController = class extends Controller {
17309
18731
  wrap: { type: Boolean, default: true }
17310
18732
  };
17311
18733
  static actions = ["onKeydown"];
17312
- static events = ["change"];
17313
- /** Current numeric value per segment kind (hours are the *displayed* hours). */
17314
- #state = { hour: 0, minute: 0, second: 0, meridiem: AM };
18734
+ static events = ["change", "reconcile"];
18735
+ /** Collapses target, Value, and retained-attribute morphs into one silent render. */
18736
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
18737
+ /** Canonical state; the displayed hour and meridiem are derived from this value. */
18738
+ #state = { hour: 0, minute: 0, second: 0 };
17315
18739
  /** Direct-entry digit buffer and the segment it belongs to. */
17316
18740
  #typeBuffer = "";
17317
18741
  #typeSegment = null;
17318
- /** Last composed field value, to suppress duplicate `change` dispatches. */
18742
+ /** Last composed value, used to suppress duplicate user notifications. */
17319
18743
  #lastValue = "";
17320
- /** Seeds each segment from its initial `aria-valuenow` and syncs the field. */
18744
+ #observer = null;
18745
+ #connected = false;
18746
+ #domDirty = false;
18747
+ /** Seeds state from the DOM, renders canonical ARIA, and starts morph observation. */
17321
18748
  connect() {
17322
- for (const segment of this.segmentTargets) {
17323
- const kind = this.#kindOf(segment);
17324
- if (!kind) continue;
17325
- const now = Number(segment.getAttribute("aria-valuenow"));
17326
- const { min, max } = this.#bounds(kind);
17327
- this.#state[kind] = Number.isFinite(now) ? Math.min(max, Math.max(min, now)) : min;
17328
- }
17329
- for (const segment of this.segmentTargets) this.#renderSegment(segment);
17330
- this.#syncField(false);
18749
+ this.#connected = true;
18750
+ this.#reconcile.activate();
18751
+ this.element.addEventListener("focusout", this.#onFocusOut);
18752
+ this.#domDirty = false;
18753
+ this.#adoptDomState();
18754
+ this.#render();
18755
+ this.#observeMutations();
18756
+ }
18757
+ /** Releases the observer, listener, queued reconciliation, and transient input state. */
18758
+ disconnect() {
18759
+ this.#connected = false;
18760
+ this.#reconcile.cancel();
18761
+ this.element.removeEventListener("focusout", this.#onFocusOut);
18762
+ this.#observer?.disconnect();
18763
+ this.#observer = null;
18764
+ this.#domDirty = false;
18765
+ this.#clearTypeBuffer();
18766
+ this.#lastValue = "";
18767
+ }
18768
+ /** Re-renders the canonical instant when 12/24-hour presentation changes at runtime. */
18769
+ hourCycleValueChanged() {
18770
+ this.#reconcile.schedule();
17331
18771
  }
17332
- /** Handles stepping, inter-segment focus moves, jumps, and direct entry. */
18772
+ /** Recomposes the optional seconds portion after a runtime Value change. */
18773
+ secondsValueChanged() {
18774
+ this.#reconcile.schedule();
18775
+ }
18776
+ /** Adopts a segment inserted or replaced by a Turbo morph. */
18777
+ segmentTargetConnected() {
18778
+ this.#domDirty = true;
18779
+ this.#clearTypeBuffer();
18780
+ this.#reconcile.schedule();
18781
+ }
18782
+ /** Rebuilds state after a segment leaves the retained controller element. */
18783
+ segmentTargetDisconnected() {
18784
+ this.#domDirty = true;
18785
+ this.#clearTypeBuffer();
18786
+ this.#reconcile.schedule();
18787
+ }
18788
+ /** Reflects the current composed value into a field added or replaced at runtime. */
18789
+ fieldTargetConnected() {
18790
+ this.#reconcile.schedule();
18791
+ }
18792
+ /** Reconciles silently when the optional field leaves at runtime. */
18793
+ fieldTargetDisconnected() {
18794
+ this.#reconcile.schedule();
18795
+ }
18796
+ /** Handles APG stepping, inter-segment focus moves, jumps, and direct entry. */
17333
18797
  onKeydown(event) {
17334
18798
  if (isReservedArrowChord(event)) return;
17335
18799
  const segment = event.target?.closest(
17336
- "[data-stimeo--time-picker-target='segment']"
18800
+ "[data-stimeo--time-picker-target~='segment']"
17337
18801
  );
17338
- const kind = segment ? this.#kindOf(segment) : null;
17339
- if (!segment || !kind) return;
17340
- switch (logicalArrowKey(event.key, this.element)) {
18802
+ if (!segment || !this.segmentTargets.includes(segment)) return;
18803
+ const kind = this.#kindOf(segment);
18804
+ if (!kind) return;
18805
+ const key = logicalArrowKey(event.key, this.element);
18806
+ if ((key === "Home" || key === "End") && hasModifier2(event)) return;
18807
+ switch (key) {
17341
18808
  case "ArrowUp":
17342
18809
  event.preventDefault();
17343
18810
  this.#step(kind, this.#delta(kind));
@@ -17356,134 +18823,202 @@ var TimePickerController = class extends Controller {
17356
18823
  break;
17357
18824
  case "Home":
17358
18825
  event.preventDefault();
17359
- this.#set(kind, this.#bounds(kind).min);
18826
+ this.#setDisplayed(kind, this.#bounds(kind).min);
18827
+ this.#commitRender();
17360
18828
  break;
17361
18829
  case "End":
17362
18830
  event.preventDefault();
17363
- this.#set(kind, this.#bounds(kind).max);
18831
+ this.#setDisplayed(kind, this.#bounds(kind).max);
18832
+ this.#commitRender();
17364
18833
  break;
17365
18834
  default:
17366
- if (/^[0-9]$/.test(event.key)) {
18835
+ if (kind !== "meridiem" && /^[0-9]$/.test(event.key) && !hasModifier2(event)) {
17367
18836
  event.preventDefault();
17368
18837
  this.#typeDigit(segment, kind, event.key);
18838
+ return;
17369
18839
  }
17370
18840
  return;
17371
18841
  }
17372
- this.#typeBuffer = "";
17373
- this.#typeSegment = null;
18842
+ this.#clearTypeBuffer();
17374
18843
  }
17375
- /** The per-step delta: minutes step by `step`, others by 1, meridiem toggles. */
18844
+ /** Clears direct-entry state when Tab, Shift+Tab, or pointer focus leaves a segment. */
18845
+ #onFocusOut = (event) => {
18846
+ const segment = event.target?.closest(
18847
+ "[data-stimeo--time-picker-target~='segment']"
18848
+ );
18849
+ if (!segment || !this.segmentTargets.includes(segment)) return;
18850
+ this.#clearTypeBuffer();
18851
+ };
18852
+ /** Uses the normalized positive-integer minute step; other segments step by one. */
17376
18853
  #delta(kind) {
17377
- return kind === "minute" ? this.stepValue : 1;
18854
+ if (kind !== "minute") return 1;
18855
+ const step = Math.trunc(this.stepValue);
18856
+ return Number.isFinite(step) && step > 0 ? step : 1;
17378
18857
  }
17379
- /** Steps a segment, wrapping at its bounds and carrying over when enabled. */
18858
+ /** Mutates one complete action and then renders/notifies its final value once. */
17380
18859
  #step(kind, delta) {
17381
- if (kind === "meridiem") {
17382
- this.#set("meridiem", this.#state.meridiem === AM ? PM : AM);
17383
- return;
17384
- }
17385
- const { min, max } = this.#bounds(kind);
17386
- const span = max - min + 1;
17387
- const raw = this.#state[kind] + delta;
17388
- if (raw > max || raw < min) {
17389
- if (!this.wrapValue) {
17390
- this.#set(kind, Math.min(max, Math.max(min, raw)));
17391
- return;
17392
- }
17393
- const wrapped = ((raw - min) % span + span) % span + min;
17394
- const carry = Math.floor((raw - min) / span);
17395
- this.#set(kind, wrapped);
17396
- this.#carry(kind, carry);
17397
- return;
18860
+ if (kind === "meridiem" && this.wrapValue) {
18861
+ this.#state.hour = modulo(this.#state.hour + 12, 24);
18862
+ } else if (this.wrapValue) {
18863
+ const scale = kind === "hour" ? SECONDS_PER_HOUR : kind === "minute" ? SECONDS_PER_MINUTE : 1;
18864
+ this.#setFromSeconds(this.#secondsOfDay + delta * scale);
18865
+ } else if (kind === "hour") {
18866
+ this.#state.hour = Math.min(23, Math.max(0, this.#state.hour + delta));
18867
+ } else {
18868
+ const { min, max } = this.#bounds(kind);
18869
+ const displayed = this.#displayValue(kind);
18870
+ this.#setDisplayed(kind, Math.min(max, Math.max(min, displayed + delta)));
17398
18871
  }
17399
- this.#set(kind, raw);
17400
- }
17401
- /** Propagates a wrap carry from `kind` into the next larger segment. */
17402
- #carry(kind, amount) {
17403
- if (kind === "second") this.#step("minute", amount);
17404
- else if (kind === "minute") this.#step("hour", amount);
18872
+ this.#commitRender();
17405
18873
  }
17406
- /**
17407
- * Sets a segment's value (clamped to its `[min, max]` bounds), re-renders it,
17408
- * and resyncs the composed field. Clamping here guards the direct-entry path:
17409
- * typing `0` into a 12-hour hour (min 1) must not commit an out-of-range
17410
- * `aria-valuenow="0"`. The stepping path already passes in-bounds values, so
17411
- * the clamp is a no-op there.
17412
- */
17413
- #set(kind, value) {
17414
- const { min, max } = this.#bounds(kind);
17415
- this.#state[kind] = Math.min(max, Math.max(min, value));
17416
- const segment = this.segmentTargets.find((s) => this.#kindOf(s) === kind);
17417
- if (segment) this.#renderSegment(segment);
17418
- this.#syncField(true);
17419
- }
17420
- /** Accumulates a typed digit, committing and advancing after two digits. */
18874
+ /** Accumulates a typed digit, rendering once and advancing after completion. */
17421
18875
  #typeDigit(segment, kind, digit) {
17422
- if (kind === "meridiem") return;
17423
18876
  if (this.#typeSegment !== kind) this.#typeBuffer = "";
17424
18877
  this.#typeSegment = kind;
17425
18878
  const { max } = this.#bounds(kind);
17426
18879
  const candidate = Number(`${this.#typeBuffer}${digit}`);
17427
- if (candidate <= max) this.#typeBuffer = `${this.#typeBuffer}${digit}`;
17428
- else this.#typeBuffer = digit;
17429
- this.#set(kind, Number(this.#typeBuffer));
18880
+ this.#typeBuffer = candidate <= max ? `${this.#typeBuffer}${digit}` : digit;
18881
+ this.#setDisplayed(kind, Number(this.#typeBuffer));
18882
+ this.#commitRender();
17430
18883
  if (this.#typeBuffer.length >= 2 || Number(this.#typeBuffer) * 10 > max) {
17431
- this.#typeBuffer = "";
17432
- this.#typeSegment = null;
18884
+ this.#clearTypeBuffer();
17433
18885
  this.#focusSibling(segment, 1);
17434
18886
  }
17435
18887
  }
17436
- /** Moves focus to the previous/next segment, if one exists. */
18888
+ /** Moves focus to the previous/next valid segment, if one exists. */
17437
18889
  #focusSibling(segment, direction) {
17438
- const index = this.segmentTargets.indexOf(segment);
17439
- const next = this.segmentTargets[index + direction];
17440
- next?.focus();
18890
+ const segments = this.segmentTargets.filter((candidate) => this.#kindOf(candidate) !== null);
18891
+ const index = segments.indexOf(segment);
18892
+ segments[index + direction]?.focus();
18893
+ }
18894
+ /** Clears the direct-entry buffer and its owning segment together. */
18895
+ #clearTypeBuffer() {
18896
+ this.#typeBuffer = "";
18897
+ this.#typeSegment = null;
18898
+ }
18899
+ /** Reconciles one coalesced target, Value, or retained-attribute mutation batch. */
18900
+ #reconcileDom() {
18901
+ if (this.#domDirty) {
18902
+ this.#domDirty = false;
18903
+ this.#clearTypeBuffer();
18904
+ this.#adoptDomState();
18905
+ }
18906
+ this.#render();
18907
+ }
18908
+ /** Rebuilds canonical state from the targets; an absent hour represents midnight. */
18909
+ #adoptDomState() {
18910
+ let displayedHour = 0;
18911
+ let meridiem = AM;
18912
+ let minute = 0;
18913
+ let second = 0;
18914
+ for (const segment of this.segmentTargets) {
18915
+ const kind = this.#kindOf(segment);
18916
+ if (!kind) continue;
18917
+ const value = this.#authoredValue(segment, kind);
18918
+ if (kind === "hour") displayedHour = value;
18919
+ else if (kind === "minute") minute = value;
18920
+ else if (kind === "second") second = value;
18921
+ else meridiem = value;
18922
+ }
18923
+ this.#state = {
18924
+ hour: this.hourCycleValue === 12 ? displayedHour % 12 + (meridiem === PM ? 12 : 0) : displayedHour,
18925
+ minute,
18926
+ second
18927
+ };
18928
+ }
18929
+ /** Reads and integer-clamps one authored `aria-valuenow`. */
18930
+ #authoredValue(segment, kind) {
18931
+ const raw = Number(segment.getAttribute("aria-valuenow"));
18932
+ const { min, max } = this.#bounds(kind);
18933
+ if (!Number.isFinite(raw)) return min;
18934
+ return Math.min(max, Math.max(min, Math.trunc(raw)));
18935
+ }
18936
+ /** Writes a displayed segment value back into canonical state without rendering. */
18937
+ #setDisplayed(kind, raw) {
18938
+ const { min, max } = this.#bounds(kind);
18939
+ const value = Math.min(max, Math.max(min, Math.trunc(raw)));
18940
+ if (kind === "hour") {
18941
+ this.#state.hour = this.hourCycleValue === 12 ? value % 12 + (this.#state.hour >= 12 ? 12 : 0) : value;
18942
+ } else if (kind === "minute" || kind === "second") {
18943
+ this.#state[kind] = value;
18944
+ } else {
18945
+ this.#state.hour = this.#state.hour % 12 + (value === PM ? 12 : 0);
18946
+ }
18947
+ }
18948
+ /** Converts seconds with day wrapping into the canonical three-unit state. */
18949
+ #setFromSeconds(raw) {
18950
+ const seconds = modulo(raw, SECONDS_PER_DAY);
18951
+ this.#state.hour = Math.floor(seconds / SECONDS_PER_HOUR);
18952
+ this.#state.minute = Math.floor(seconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
18953
+ this.#state.second = seconds % SECONDS_PER_MINUTE;
17441
18954
  }
17442
- /** Reflects a segment's current value onto its ARIA/text representation. */
18955
+ /** Reflects canonical state to every segment without dispatching user events. */
18956
+ #renderSegments() {
18957
+ this.#withoutObservation(() => {
18958
+ for (const segment of this.segmentTargets) this.#renderSegment(segment);
18959
+ });
18960
+ }
18961
+ /**
18962
+ * Reflects canonical state during connection or DOM reconciliation.
18963
+ *
18964
+ * No user edit reaches here, so `change` never fires from this path. A pass
18965
+ * that moves the committed value reports `reconcile` instead.
18966
+ *
18967
+ * @stimeoRenderRoot
18968
+ */
18969
+ #render() {
18970
+ this.#renderSegments();
18971
+ const value = this.#composedValue;
18972
+ const previous = this.#lastValue;
18973
+ this.#writeField(value);
18974
+ this.#lastValue = value;
18975
+ if (previous !== "" && value !== previous) {
18976
+ this.dispatch("reconcile", { detail: { value } });
18977
+ }
18978
+ }
18979
+ /** Reflects one completed user action and dispatches its final change exactly once. */
18980
+ #commitRender() {
18981
+ this.#renderSegments();
18982
+ const value = this.#composedValue;
18983
+ const fieldChanged = this.#writeField(value);
18984
+ if (fieldChanged) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
18985
+ if (value !== this.#lastValue) this.dispatch("change", { detail: { value } });
18986
+ this.#lastValue = value;
18987
+ }
18988
+ /** Reflects one segment's value and controller-owned ARIA bounds/text. */
17443
18989
  #renderSegment(segment) {
17444
18990
  const kind = this.#kindOf(segment);
17445
18991
  if (!kind) return;
17446
- const value = this.#state[kind];
17447
- if (kind === "meridiem") {
17448
- const text2 = value === PM ? "PM" : "AM";
17449
- segment.setAttribute("aria-valuenow", String(value));
17450
- segment.setAttribute("aria-valuetext", text2);
17451
- segment.setAttribute("aria-valuemin", String(AM));
17452
- segment.setAttribute("aria-valuemax", String(PM));
17453
- segment.textContent = text2;
17454
- return;
17455
- }
18992
+ const value = this.#displayValue(kind);
18993
+ const text = kind === "meridiem" ? value === PM ? "PM" : "AM" : pad(value);
17456
18994
  const { min, max } = this.#bounds(kind);
17457
- const text = String(value).padStart(2, "0");
17458
18995
  segment.setAttribute("aria-valuenow", String(value));
17459
18996
  segment.setAttribute("aria-valuetext", text);
17460
18997
  segment.setAttribute("aria-valuemin", String(min));
17461
18998
  segment.setAttribute("aria-valuemax", String(max));
17462
18999
  segment.textContent = text;
17463
19000
  }
17464
- /** Composes `HH:MM[:SS]` and notifies both native-form and widget consumers. */
17465
- #syncField(notify) {
17466
- const h24 = this.#hours24();
17467
- const parts = [pad(h24), pad(this.#state.minute)];
19001
+ /** Writes a composed value to the optional form field and reports whether it changed. */
19002
+ #writeField(value) {
19003
+ if (!this.hasFieldTarget || this.fieldTarget.value === value) return false;
19004
+ this.fieldTarget.value = value;
19005
+ return true;
19006
+ }
19007
+ /** The canonical form value composed as `HH:MM[:SS]`. */
19008
+ get #composedValue() {
19009
+ const parts = [pad(this.#state.hour), pad(this.#state.minute)];
17468
19010
  if (this.secondsValue) parts.push(pad(this.#state.second));
17469
- const value = parts.join(":");
17470
- const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;
17471
- if (fieldChanged) {
17472
- this.fieldTarget.value = value;
17473
- }
17474
- if (notify && fieldChanged) {
17475
- this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
17476
- }
17477
- if (notify && value !== this.#lastValue) this.dispatch("change", { detail: { value } });
17478
- this.#lastValue = value;
19011
+ return parts.join(":");
17479
19012
  }
17480
- /** Converts the displayed hour (+ meridiem in 12-hour mode) to 24-hour. */
17481
- #hours24() {
17482
- if (this.hourCycleValue !== 12) return this.#state.hour;
17483
- const base = this.#state.hour % 12;
17484
- return base + (this.#state.meridiem === PM ? 12 : 0);
19013
+ /** Returns a canonical unit in the presentation form exposed by its segment. */
19014
+ #displayValue(kind) {
19015
+ if (kind === "hour") {
19016
+ return this.hourCycleValue === 12 ? this.#state.hour % 12 || 12 : this.#state.hour;
19017
+ }
19018
+ if (kind === "meridiem") return this.#state.hour >= 12 ? PM : AM;
19019
+ return this.#state[kind];
17485
19020
  }
17486
- /** The inclusive `[min, max]` bounds for a segment kind. */
19021
+ /** The inclusive controller-owned bounds for a segment kind. */
17487
19022
  #bounds(kind) {
17488
19023
  switch (kind) {
17489
19024
  case "hour":
@@ -17495,6 +19030,38 @@ var TimePickerController = class extends Controller {
17495
19030
  return { min: AM, max: PM };
17496
19031
  }
17497
19032
  }
19033
+ /** Watches retained segment state while excluding this controller's own reflections. */
19034
+ #observeMutations() {
19035
+ const observer = new MutationObserver((records) => {
19036
+ const changed = records.some(
19037
+ ({ target }) => target instanceof HTMLElement && this.segmentTargets.includes(target)
19038
+ );
19039
+ if (!changed) return;
19040
+ this.#domDirty = true;
19041
+ this.#clearTypeBuffer();
19042
+ this.#reconcile.schedule();
19043
+ });
19044
+ this.#observer = observer;
19045
+ this.#observeWith(observer);
19046
+ }
19047
+ /** Registers retained attribute observation on the controller subtree. */
19048
+ #observeWith(observer) {
19049
+ observer.observe(this.element, {
19050
+ subtree: true,
19051
+ attributes: true,
19052
+ attributeFilter: ["aria-valuenow", "data-segment"]
19053
+ });
19054
+ }
19055
+ /** Temporarily pauses observation so derived ARIA writes cannot become DOM input. */
19056
+ #withoutObservation(run) {
19057
+ const observer = this.#observer;
19058
+ observer?.disconnect();
19059
+ try {
19060
+ run();
19061
+ } finally {
19062
+ if (observer && this.#connected) this.#observeWith(observer);
19063
+ }
19064
+ }
17498
19065
  /** Reads a segment's declared kind, or null when absent/invalid. */
17499
19066
  #kindOf(segment) {
17500
19067
  const kind = segment.getAttribute("data-segment");
@@ -17503,7 +19070,14 @@ var TimePickerController = class extends Controller {
17503
19070
  }
17504
19071
  return null;
17505
19072
  }
19073
+ /** Canonical state as seconds since the start of its nominal day. */
19074
+ get #secondsOfDay() {
19075
+ return this.#state.hour * SECONDS_PER_HOUR + this.#state.minute * SECONDS_PER_MINUTE + this.#state.second;
19076
+ }
17506
19077
  };
19078
+ function modulo(value, modulus) {
19079
+ return (value % modulus + modulus) % modulus;
19080
+ }
17507
19081
  function pad(value) {
17508
19082
  return String(value).padStart(2, "0");
17509
19083
  }
@@ -17826,6 +19400,16 @@ var ToastController = class extends Controller {
17826
19400
  this.#rafHandles.delete(element);
17827
19401
  }
17828
19402
  };
19403
+ var ITEM_ATTRIBUTES = [
19404
+ "aria-pressed",
19405
+ "disabled",
19406
+ "hidden",
19407
+ "type",
19408
+ "href",
19409
+ "contenteditable",
19410
+ "controls"
19411
+ ];
19412
+ var hasModifier3 = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
17829
19413
  var ToggleGroupController = class extends Controller {
17830
19414
  static targets = ["item"];
17831
19415
  static values = {
@@ -17833,90 +19417,362 @@ var ToggleGroupController = class extends Controller {
17833
19417
  };
17834
19418
  static actions = ["onKeydown", "toggle"];
17835
19419
  static events = ["change"];
17836
- #roving = new RovingTabindex(() => this.itemTargets);
17837
- /** Establishes the roving entry point (first pressed item, else the first). */
19420
+ #roving = new RovingTabindex(() => this.#managedTargets);
19421
+ #handledEvents = /* @__PURE__ */ new WeakSet();
19422
+ #managedItems = /* @__PURE__ */ new Set();
19423
+ #originalTabindex = /* @__PURE__ */ new Map();
19424
+ #ownedPressed = /* @__PURE__ */ new Set();
19425
+ #internalPressedValues = /* @__PURE__ */ new Map();
19426
+ #observer = null;
19427
+ #connected = false;
19428
+ /**
19429
+ * Normalizes state and establishes one roving entry point. A single authored
19430
+ * `tabindex="0"` survives reconnect; otherwise the first pressed, navigable
19431
+ * item wins, falling back to the first navigable item.
19432
+ */
17838
19433
  connect() {
17839
- const firstPressed = this.itemTargets.findIndex((item) => this.#isPressed(item));
17840
- this.#roving.setActive(firstPressed === -1 ? 0 : firstPressed);
19434
+ const currentItems = new Set(this.itemTargets);
19435
+ for (const item of this.#managedItems) {
19436
+ if (!currentItems.has(item)) this.#releaseItem(item);
19437
+ }
19438
+ const authoredStops = this.itemTargets.filter(
19439
+ (item) => this.#isSupportedHost(item) && item.getAttribute("tabindex") === "0" && this.#isNavigable(item)
19440
+ );
19441
+ const preferred = authoredStops.length === 1 ? authoredStops[0] : null;
19442
+ for (const item of this.itemTargets) this.#reconcileHost(item, false);
19443
+ this.#normalizeSelection();
19444
+ this.#ensureTabStop(preferred);
19445
+ this.element.addEventListener("click", this.#onClickCapture, true);
19446
+ this.element.addEventListener("keydown", this.#onKeydownCapture, true);
19447
+ this.element.addEventListener("click", this.#onClick);
19448
+ this.element.addEventListener("keydown", this.#onKeydown);
19449
+ this.element.addEventListener("focusin", this.#onFocusin);
19450
+ this.#connected = true;
19451
+ this.#internalPressedValues.clear();
19452
+ this.#observeMutations();
17841
19453
  }
17842
- /** Toggles the activated item. Bound via `data-action` (click). */
19454
+ /** Releases every listener and observer while retaining DOM state for Turbo cache/reconnect. */
19455
+ disconnect() {
19456
+ this.#connected = false;
19457
+ this.element.removeEventListener("click", this.#onClickCapture, true);
19458
+ this.element.removeEventListener("keydown", this.#onKeydownCapture, true);
19459
+ this.element.removeEventListener("click", this.#onClick);
19460
+ this.element.removeEventListener("keydown", this.#onKeydown);
19461
+ this.element.removeEventListener("focusin", this.#onFocusin);
19462
+ this.#observer?.disconnect();
19463
+ this.#observer = null;
19464
+ this.#internalPressedValues.clear();
19465
+ }
19466
+ /** Drops a newly connected item from the Tab sequence before reconciling the group. */
19467
+ itemTargetConnected(item) {
19468
+ if (!this.#connected) return;
19469
+ this.#reconcileHost(item, true);
19470
+ this.#normalizeSelection();
19471
+ this.#ensureTabStop();
19472
+ }
19473
+ /** Restores attributes owned only while an element is an item and repairs the Tab stop. */
19474
+ itemTargetDisconnected(item) {
19475
+ if (!this.#connected) return;
19476
+ this.#releaseItem(item);
19477
+ this.#normalizeSelection();
19478
+ this.#ensureTabStop();
19479
+ }
19480
+ /** Reconciles the single-selection invariant when the Stimulus Value changes. */
19481
+ modeValueChanged() {
19482
+ if (!this.#connected) return;
19483
+ this.#normalizeSelection();
19484
+ this.#ensureTabStop();
19485
+ }
19486
+ /**
19487
+ * Toggles the action's item. Per-item action wiring is optional because click
19488
+ * is also delegated from the group.
19489
+ */
17843
19490
  toggle(event) {
17844
- this.#toggleIndex(this.itemTargets.indexOf(event.currentTarget));
19491
+ if (event.defaultPrevented) return;
19492
+ const item = event.currentTarget;
19493
+ if (!item || !this.itemTargets.includes(item)) return;
19494
+ this.#handledEvents.add(event);
19495
+ this.#toggleItem(item);
17845
19496
  }
17846
- /** Arrow/Home/End move focus only; Space/Enter toggle non-button hosts. */
19497
+ /**
19498
+ * Moves focus or activates the action's item. Per-item action wiring is
19499
+ * optional because keydown is also delegated from the group.
19500
+ */
17847
19501
  onKeydown(event) {
17848
- if (event.defaultPrevented) return;
19502
+ const item = event.currentTarget;
19503
+ if (!item || !this.itemTargets.includes(item)) return;
19504
+ this.#handleKeydown(event, item);
19505
+ }
19506
+ /** Blocks disabled activation before per-item or consumer event handlers run. */
19507
+ #onClickCapture = (event) => {
19508
+ const item = this.#itemForEventTarget(event.target);
19509
+ if (!item || !this.#isSupportedHost(item) || !this.#isActivationDisabled(item)) return;
19510
+ event.preventDefault();
19511
+ event.stopImmediatePropagation();
19512
+ };
19513
+ /** Blocks disabled keyboard activation while leaving navigation keys available. */
19514
+ #onKeydownCapture = (event) => {
19515
+ if (event.key !== " " && event.key !== "Enter") return;
19516
+ const item = this.#itemForEventTarget(event.target);
19517
+ if (!item || !this.#isSupportedHost(item) || !this.#isActivationDisabled(item)) return;
19518
+ event.preventDefault();
19519
+ event.stopImmediatePropagation();
19520
+ };
19521
+ /** Delegated click path for static and runtime-added items without actions. */
19522
+ #onClick = (event) => {
19523
+ if (this.#handledEvents.delete(event) || event.defaultPrevented) return;
19524
+ const item = this.#itemForEventTarget(event.target);
19525
+ if (item) this.#toggleItem(item);
19526
+ };
19527
+ /** Delegated keydown path for static and runtime-added items without actions. */
19528
+ #onKeydown = (event) => {
19529
+ const item = this.#itemForEventTarget(event.target);
19530
+ if (item) this.#handleKeydown(event, item);
19531
+ };
19532
+ /** Keeps programmatic and pointer focus as the group's next Tab entry point. */
19533
+ #onFocusin = (event) => {
19534
+ const item = this.#itemForEventTarget(event.target);
19535
+ if (!item || !this.#isNavigable(item)) return;
19536
+ this.#setActive(item);
19537
+ };
19538
+ /** Applies the APG navigation and activation key map to one supported item. */
19539
+ #handleKeydown(event, item) {
19540
+ if (event.defaultPrevented || !this.#isSupportedHost(item)) return;
19541
+ if (event.isComposing) return;
17849
19542
  if (isReservedArrowChord(event)) return;
17850
- const current = this.itemTargets.indexOf(event.currentTarget);
17851
- if (current === -1) return;
17852
- let next = null;
17853
- const rtl = isRtl(this.element);
17854
- const horizontalStep = (key) => rtl ? key === "ArrowRight" ? -1 : 1 : key === "ArrowRight" ? 1 : -1;
17855
- switch (event.key) {
17856
- case "ArrowRight":
17857
- case "ArrowDown":
17858
- next = rovingMove(
17859
- current,
17860
- this.itemTargets.length,
17861
- event.key === "ArrowDown" ? 1 : horizontalStep(event.key),
17862
- "wrap"
17863
- );
17864
- break;
17865
- case "ArrowLeft":
17866
- case "ArrowUp":
17867
- next = rovingMove(
17868
- current,
17869
- this.itemTargets.length,
17870
- event.key === "ArrowUp" ? -1 : horizontalStep(event.key),
17871
- "wrap"
17872
- );
17873
- break;
17874
- case "Home":
17875
- next = 0;
17876
- break;
17877
- case "End":
17878
- next = this.itemTargets.length - 1;
17879
- break;
17880
- case " ":
17881
- case "Enter":
17882
- if (event.currentTarget instanceof HTMLButtonElement) return;
19543
+ if ((event.key === "Home" || event.key === "End") && hasModifier3(event)) return;
19544
+ if (!this.#managedItems.has(item)) this.#reconcileHost(item, true);
19545
+ this.#ensureTabStop();
19546
+ const items = this.#managedTargets;
19547
+ const current = items.indexOf(item);
19548
+ if (event.key === " " || event.key === "Enter") {
19549
+ if (this.#isActivationDisabled(item)) {
17883
19550
  event.preventDefault();
17884
- this.#toggleIndex(current);
19551
+ event.stopImmediatePropagation();
17885
19552
  return;
17886
- default:
19553
+ }
19554
+ if (item instanceof HTMLButtonElement) {
19555
+ if (event.repeat) event.preventDefault();
17887
19556
  return;
19557
+ }
19558
+ event.preventDefault();
19559
+ if (!event.repeat) this.#toggleItem(item);
19560
+ return;
19561
+ }
19562
+ const step = logicalArrowStep(event.key, this.element);
19563
+ let destination;
19564
+ if (step !== 0) {
19565
+ destination = this.#nextNavigable(current, step);
19566
+ } else if (event.key === "Home") {
19567
+ destination = this.#navigableItems[0];
19568
+ } else if (event.key === "End") {
19569
+ const navigable = this.#navigableItems;
19570
+ destination = navigable[navigable.length - 1];
19571
+ } else {
19572
+ return;
17888
19573
  }
17889
19574
  event.preventDefault();
17890
- this.#roving.setActive(next, { focus: true });
19575
+ if (destination) this.#setActive(destination, true);
17891
19576
  }
17892
- /** Applies the toggle at `index` per the current mode and dispatches `change`. */
17893
- #toggleIndex(index) {
17894
- const item = this.itemTargets[index];
17895
- if (!item) return;
19577
+ /** Applies one user toggle and dispatches the documented change detail. */
19578
+ #toggleItem(item) {
19579
+ if (!this.#isSupportedHost(item) || this.#isActivationDisabled(item)) return;
19580
+ if (!this.#managedItems.has(item)) this.#reconcileHost(item, true);
17896
19581
  const willPress = !this.#isPressed(item);
17897
19582
  if (this.modeValue === "single") {
17898
- this.itemTargets.forEach((other, i) => {
17899
- this.#setPressed(other, i === index && willPress);
17900
- });
19583
+ for (const other of this.#managedTargets)
19584
+ this.#setPressed(other, other === item && willPress);
17901
19585
  } else {
17902
19586
  this.#setPressed(item, willPress);
17903
19587
  }
17904
- this.#roving.setActive(index);
19588
+ this.#setActive(item);
17905
19589
  this.dispatch("change", {
17906
19590
  detail: { value: this.#itemValue(item), pressed: willPress, values: this.#pressedValues() }
17907
19591
  });
17908
19592
  }
19593
+ /** Reconciles supported hosts, pressed state, and the roving invariant after a DOM mutation. */
19594
+ #reconcile() {
19595
+ for (const item of this.itemTargets) this.#reconcileHost(item, true);
19596
+ this.#normalizeSelection();
19597
+ this.#ensureTabStop();
19598
+ }
19599
+ /** Begins or ends ownership according to the item's current host semantics. */
19600
+ #reconcileHost(item, dropFromTabSequence) {
19601
+ if (!this.#isSupportedHost(item)) {
19602
+ this.#releaseItem(item);
19603
+ return;
19604
+ }
19605
+ if (!this.#managedItems.has(item)) {
19606
+ this.#managedItems.add(item);
19607
+ this.#originalTabindex.set(item, item.getAttribute("tabindex"));
19608
+ if (dropFromTabSequence) item.tabIndex = -1;
19609
+ }
19610
+ this.#normalizePressed(item);
19611
+ }
19612
+ /** Restores only defaults owned because an element belonged to this group. */
19613
+ #releaseItem(item) {
19614
+ if (!this.#managedItems.delete(item)) return;
19615
+ const original = this.#originalTabindex.get(item);
19616
+ if (original === null) item.removeAttribute("tabindex");
19617
+ else if (original !== void 0) item.setAttribute("tabindex", original);
19618
+ this.#originalTabindex.delete(item);
19619
+ if (this.#ownedPressed.delete(item)) item.removeAttribute("aria-pressed");
19620
+ this.#internalPressedValues.delete(item);
19621
+ }
19622
+ /** Supplies a missing pressed state and normalizes invalid ARIA tokens. */
19623
+ #normalizePressed(item) {
19624
+ const value = item.getAttribute("aria-pressed");
19625
+ if (value === null) {
19626
+ this.#ownedPressed.add(item);
19627
+ this.#internalPressedValues.set(item, "false");
19628
+ item.setAttribute("aria-pressed", "false");
19629
+ } else if (value !== "true" && value !== "false") {
19630
+ item.setAttribute("aria-pressed", "false");
19631
+ }
19632
+ }
19633
+ /** Makes the first DOM-ordered pressed item the sole pressed item in single mode. */
19634
+ #normalizeSelection() {
19635
+ for (const item of this.#managedTargets) this.#normalizePressed(item);
19636
+ if (this.modeValue !== "single") return;
19637
+ let found = false;
19638
+ for (const item of this.#managedTargets) {
19639
+ if (!this.#isPressed(item)) continue;
19640
+ if (!found) found = true;
19641
+ else this.#setPressed(item, false);
19642
+ }
19643
+ }
19644
+ /** Keeps one navigable Tab stop, preferring current DOM state after initial connect. */
19645
+ #ensureTabStop(initialPreference) {
19646
+ const navigable = this.#navigableItems;
19647
+ let active;
19648
+ if (initialPreference !== void 0) {
19649
+ active = initialPreference ?? void 0;
19650
+ } else {
19651
+ const current = this.#managedTargets.filter(
19652
+ (item) => item.tabIndex === 0 && this.#isNavigable(item)
19653
+ );
19654
+ if (current.length === 1) active = current[0];
19655
+ }
19656
+ active ??= navigable.find((item) => this.#isPressed(item));
19657
+ active ??= navigable[0];
19658
+ this.#setActive(active ?? null);
19659
+ }
19660
+ /** Moves through the full item order until a navigable destination is found. */
19661
+ #nextNavigable(fromIndex, delta) {
19662
+ const items = this.#managedTargets;
19663
+ let index = fromIndex;
19664
+ for (let step = 0; step < items.length; step++) {
19665
+ index = rovingMove(index, items.length, delta, "wrap");
19666
+ const candidate = items[index];
19667
+ if (candidate && this.#isNavigable(candidate)) return candidate;
19668
+ }
19669
+ return void 0;
19670
+ }
19671
+ /** Assigns the single roving Tab stop and optionally moves DOM focus. */
19672
+ #setActive(item, focus = false) {
19673
+ this.#roving.setActive(this.#managedTargets.indexOf(item), { focus });
19674
+ }
19675
+ /** Finds this group's item containing an event target, excluding nested groups. */
19676
+ #itemForEventTarget(target) {
19677
+ if (!this.#ownsEventTarget(target)) return void 0;
19678
+ const node = target;
19679
+ return this.itemTargets.find((item) => item === node || item.contains(node));
19680
+ }
19681
+ /** Whether the closest Toggle Group scope around a target is this instance. */
19682
+ #ownsEventTarget(target) {
19683
+ return target instanceof Element && target.closest('[data-controller~="stimeo--toggle-group"]') === this.element;
19684
+ }
19685
+ /** Hosts whose activation model can be owned without conflicting native behavior. */
19686
+ #isSupportedHost(item) {
19687
+ if (item instanceof HTMLButtonElement) return item.type === "button";
19688
+ return !isInteractiveHost(item);
19689
+ }
19690
+ /** Items eligible for roving focus (ARIA-disabled deliberately remains eligible). */
19691
+ #isNavigable(item) {
19692
+ if (!this.#isSupportedHost(item) || this.#isHidden(item)) return false;
19693
+ if (!(item instanceof HTMLButtonElement)) return true;
19694
+ return !item.disabled && !inheritsFieldsetDisabled(item);
19695
+ }
19696
+ /** Whether hidden applies on the path from an item up to the group root. */
19697
+ #isHidden(item) {
19698
+ let current = item;
19699
+ while (current && current !== this.element) {
19700
+ if (current.hasAttribute("hidden")) return true;
19701
+ current = current.parentElement;
19702
+ }
19703
+ return false;
19704
+ }
19705
+ /** Whether ARIA, visibility, or native HTML semantics suppress activation. */
19706
+ #isActivationDisabled(item) {
19707
+ if (!this.#isNavigable(item)) return true;
19708
+ let current = item;
19709
+ while (current) {
19710
+ if (current.getAttribute("aria-disabled") === "true") return true;
19711
+ current = current.parentElement;
19712
+ }
19713
+ return false;
19714
+ }
19715
+ /** Releases default ownership when a Turbo morph authors `aria-pressed`. */
19716
+ #releaseAuthoredPressed(records) {
19717
+ for (const record of records) {
19718
+ if (record.attributeName !== "aria-pressed") continue;
19719
+ const item = record.target;
19720
+ const value = item.getAttribute("aria-pressed");
19721
+ if (value !== null && this.#internalPressedValues.get(item) !== value) {
19722
+ this.#ownedPressed.delete(item);
19723
+ }
19724
+ }
19725
+ this.#internalPressedValues.clear();
19726
+ }
19727
+ /** Watches target membership, retained item hosts/state, and inherited host/fieldset state. */
19728
+ #observeMutations() {
19729
+ const observer = new MutationObserver((records) => {
19730
+ this.#releaseAuthoredPressed(records);
19731
+ observer.disconnect();
19732
+ this.#reconcile();
19733
+ this.#observeWith(observer);
19734
+ });
19735
+ this.#observer = observer;
19736
+ this.#observeWith(observer);
19737
+ }
19738
+ /** Registers every root and ancestor observation on one observer instance. */
19739
+ #observeWith(observer) {
19740
+ observer.observe(this.element, {
19741
+ subtree: true,
19742
+ childList: true,
19743
+ attributes: true,
19744
+ attributeFilter: ITEM_ATTRIBUTES
19745
+ });
19746
+ let ancestor = this.element.parentElement;
19747
+ while (ancestor) {
19748
+ observer.observe(ancestor, {
19749
+ attributes: true,
19750
+ attributeFilter: ["contenteditable", "disabled"]
19751
+ });
19752
+ ancestor = ancestor.parentElement;
19753
+ }
19754
+ }
19755
+ /** Supported targets currently managed by the roving primitive. */
19756
+ get #managedTargets() {
19757
+ return this.itemTargets.filter((item) => this.#managedItems.has(item));
19758
+ }
19759
+ /** Managed items eligible for the roving Tab stop. */
19760
+ get #navigableItems() {
19761
+ return this.#managedTargets.filter((item) => this.#isNavigable(item));
19762
+ }
17909
19763
  /** Whether an item is currently pressed. */
17910
19764
  #isPressed(item) {
17911
19765
  return item.getAttribute("aria-pressed") === "true";
17912
19766
  }
17913
- /** Reflects the pressed state on `aria-pressed`. */
19767
+ /** Reflects pressed state while distinguishing controller writes from authored morphs. */
17914
19768
  #setPressed(item, pressed) {
17915
- item.setAttribute("aria-pressed", pressed ? "true" : "false");
19769
+ const value = pressed ? "true" : "false";
19770
+ this.#internalPressedValues.set(item, value);
19771
+ item.setAttribute("aria-pressed", value);
17916
19772
  }
17917
- /** The `data-value` of every currently pressed item. */
19773
+ /** The DOM-ordered values of every currently pressed managed item. */
17918
19774
  #pressedValues() {
17919
- return this.itemTargets.filter((item) => this.#isPressed(item)).map((item) => this.#itemValue(item));
19775
+ return this.#managedTargets.filter((item) => this.#isPressed(item)).map((item) => this.#itemValue(item));
17920
19776
  }
17921
19777
  /** An item's value (`data-value`, defaulting to empty). */
17922
19778
  #itemValue(item) {