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.
- package/CHANGELOG.md +116 -0
- package/dist/controllers/aspect_ratio_controller.d.ts +1 -3
- package/dist/controllers/aspect_ratio_controller.js +19 -11
- package/dist/controllers/aspect_ratio_controller.js.map +1 -1
- package/dist/controllers/avatar_controller.d.ts +36 -15
- package/dist/controllers/avatar_controller.js +195 -40
- package/dist/controllers/avatar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +12 -3
- package/dist/controllers/carousel_controller.js +85 -9
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.d.ts +31 -5
- package/dist/controllers/checkbox_controller.js +136 -25
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +7 -1
- package/dist/controllers/color_picker_controller.js +35 -9
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +23 -4
- package/dist/controllers/date_range_picker_controller.js +157 -30
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.d.ts +7 -1
- package/dist/controllers/file_dropzone_controller.js +26 -3
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +2 -3
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +2 -2
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +3 -3
- package/dist/controllers/idle_controller.js +27 -5
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.js +5 -3
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +46 -12
- package/dist/controllers/multi_select_controller.js +460 -151
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +24 -6
- package/dist/controllers/number_input_controller.js +275 -51
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.js +4 -0
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.js +33 -0
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_strength_controller.d.ts +3 -3
- package/dist/controllers/password_strength_controller.js +20 -2
- package/dist/controllers/password_strength_controller.js.map +1 -1
- package/dist/controllers/persist_controller.d.ts +4 -4
- package/dist/controllers/persist_controller.js +24 -5
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +2 -2
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +1 -2
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +41 -15
- package/dist/controllers/radio_group_controller.js +540 -56
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +36 -31
- package/dist/controllers/rating_controller.js +272 -89
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.js +33 -0
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.d.ts +4 -0
- package/dist/controllers/roving_controller.js +60 -5
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.d.ts +6 -0
- package/dist/controllers/scroll_area_controller.js +154 -22
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.js +33 -0
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +41 -11
- package/dist/controllers/tags_input_controller.js +356 -120
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +42 -13
- package/dist/controllers/time_picker_controller.js +296 -107
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.d.ts +39 -13
- package/dist/controllers/toggle_group_controller.js +378 -55
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +5 -3
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +2 -2
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +2 -3
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.js +5 -3
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +2637 -781
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +16 -3
- package/dist/inspector/cli.js +22 -6
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +22 -6
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +8 -8
- package/dist/inspector/manifest.json +169 -37
- package/dist/positioning/index.d.ts +2 -2
- package/dist/positioning/index.js.map +1 -1
- 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(
|
|
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
|
|
872
|
-
if (
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
1025
|
-
|
|
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 (
|
|
1029
|
-
this
|
|
1158
|
+
if (!image.complete) {
|
|
1159
|
+
this.#reflect("loading");
|
|
1030
1160
|
return;
|
|
1031
1161
|
}
|
|
1032
|
-
this.#
|
|
1162
|
+
this.#reflect(image.naturalWidth > 0 ? "loaded" : "error");
|
|
1033
1163
|
}
|
|
1034
|
-
/**
|
|
1035
|
-
|
|
1036
|
-
this
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
const
|
|
1041
|
-
this.#
|
|
1042
|
-
|
|
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
|
-
/**
|
|
1051
|
-
#
|
|
1052
|
-
|
|
1053
|
-
if (
|
|
1054
|
-
this.
|
|
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
|
-
/**
|
|
1057
|
-
#
|
|
1058
|
-
|
|
1059
|
-
if (this.
|
|
1060
|
-
|
|
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,
|
|
1903
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
2250
|
+
picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
|
|
2098
2251
|
});
|
|
2099
|
-
this.#roving.setActive(
|
|
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
|
-
/**
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
|
2252
|
-
const
|
|
2253
|
-
this.
|
|
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(
|
|
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
|
|
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
|
-
#
|
|
2265
|
-
|
|
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
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.#
|
|
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
|
|
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
|
-
/**
|
|
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.#
|
|
4699
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4759
|
-
if (!name) return;
|
|
4760
|
-
const range = computePreset(name);
|
|
5071
|
+
const range = computePreset(button?.getAttribute("data-range") ?? "");
|
|
4761
5072
|
if (!range) return;
|
|
4762
|
-
const
|
|
4763
|
-
|
|
4764
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
4855
|
-
this.#
|
|
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 =
|
|
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 =
|
|
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.#
|
|
4911
|
-
else el.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
"
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
9494
|
-
this.#normalizeSelection();
|
|
10053
|
+
this.#warnedTemplate = false;
|
|
10054
|
+
this.#normalizeSelection([]);
|
|
9495
10055
|
this.close();
|
|
9496
10056
|
if (this.hasTagsTarget) {
|
|
9497
|
-
this.
|
|
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
|
-
|
|
9515
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
9541
|
-
|
|
9542
|
-
this.#
|
|
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
|
-
|
|
9546
|
-
|
|
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.#
|
|
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
|
|
9573
|
-
*
|
|
9574
|
-
*
|
|
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
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
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)
|
|
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 (
|
|
9647
|
-
this.listTarget.hidden = true;
|
|
10282
|
+
if (this.hasListTarget) this.listTarget.hidden = true;
|
|
9648
10283
|
this.#setActive(null);
|
|
9649
|
-
if (
|
|
9650
|
-
this
|
|
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
|
|
9699
|
-
if (
|
|
10334
|
+
const last = this.#chipRow.lastIndex;
|
|
10335
|
+
if (last >= 0) {
|
|
9700
10336
|
event.preventDefault();
|
|
9701
|
-
this.#removeTagAt(
|
|
10337
|
+
this.#removeTagAt(last, "input");
|
|
9702
10338
|
}
|
|
9703
10339
|
}
|
|
9704
10340
|
break;
|
|
9705
10341
|
case "ArrowLeft":
|
|
9706
|
-
if (this.inputTarget.value === "" && this.#
|
|
10342
|
+
if (this.inputTarget.value === "" && this.#chipRow.length > 0) {
|
|
9707
10343
|
event.preventDefault();
|
|
9708
|
-
this.#
|
|
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
|
|
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
|
-
|
|
9786
|
-
const current =
|
|
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 +
|
|
10415
|
+
/** Selects/deselects `option`, honoring `max`, and syncs chip + announcement. */
|
|
9792
10416
|
#toggleSelection(option) {
|
|
9793
10417
|
const selected = option.getAttribute("aria-selected") === "true";
|
|
9794
|
-
|
|
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
|
-
|
|
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-
|
|
9824
|
-
|
|
9825
|
-
|
|
9826
|
-
|
|
9827
|
-
|
|
9828
|
-
|
|
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.
|
|
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
|
|
9837
|
-
const label = fragment.querySelector(
|
|
9838
|
-
|
|
9839
|
-
|
|
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
|
-
|
|
9842
|
-
|
|
9843
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9865
|
-
|
|
9866
|
-
|
|
9867
|
-
|
|
9868
|
-
|
|
9869
|
-
|
|
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.
|
|
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
|
-
|
|
9965
|
-
this.#roving.setActive(0);
|
|
10625
|
+
this.#chipRow.ensureTabStop();
|
|
9966
10626
|
}
|
|
9967
|
-
/**
|
|
9968
|
-
#
|
|
9969
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
10002
|
-
get #
|
|
10003
|
-
|
|
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
|
|
10677
|
-
#
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
10696
|
-
|
|
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.#
|
|
10707
|
-
this.#
|
|
10708
|
-
this.#
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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(
|
|
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 `
|
|
10833
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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)
|
|
11631
|
+
if (!this.hasInputTarget) {
|
|
11632
|
+
this.#lastValue = null;
|
|
11633
|
+
return;
|
|
11634
|
+
}
|
|
10855
11635
|
if (this.inputTarget.value.trim() !== "") {
|
|
10856
|
-
this.#
|
|
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.#
|
|
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
|
|
10864
|
-
|
|
10865
|
-
|
|
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
|
-
|
|
10878
|
-
|
|
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(
|
|
11689
|
+
if (button.hasAttribute(OWNED_DISABLED2)) {
|
|
10889
11690
|
button.disabled = false;
|
|
10890
|
-
button.removeAttribute(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
12349
|
-
if (field.name.length > 0 &&
|
|
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
|
|
13094
|
-
|
|
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
|
|
13097
|
-
|
|
13098
|
-
|
|
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
|
-
/**
|
|
13101
|
-
|
|
13102
|
-
|
|
13103
|
-
|
|
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
|
-
/**
|
|
13106
|
-
|
|
13107
|
-
if (
|
|
13108
|
-
if (
|
|
13109
|
-
|
|
13110
|
-
|
|
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
|
-
|
|
13133
|
-
this.#
|
|
14018
|
+
this.#releaseRadio(radio);
|
|
14019
|
+
this.#preferChecked = true;
|
|
14020
|
+
this.#reconcile.schedule();
|
|
13134
14021
|
}
|
|
13135
|
-
/**
|
|
13136
|
-
|
|
13137
|
-
|
|
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
|
-
/**
|
|
13150
|
-
|
|
13151
|
-
|
|
14026
|
+
/** Reconciles after a field target is removed from a retained group. */
|
|
14027
|
+
fieldTargetDisconnected() {
|
|
14028
|
+
this.#reconcile.schedule();
|
|
13152
14029
|
}
|
|
13153
|
-
/**
|
|
13154
|
-
|
|
13155
|
-
|
|
13156
|
-
|
|
13157
|
-
|
|
13158
|
-
|
|
13159
|
-
|
|
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
|
-
/**
|
|
13167
|
-
|
|
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
|
-
|
|
13553
|
-
|
|
13554
|
-
|
|
13555
|
-
|
|
13556
|
-
|
|
13557
|
-
#
|
|
13558
|
-
|
|
13559
|
-
|
|
13560
|
-
|
|
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
|
-
|
|
13567
|
-
|
|
13568
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
13584
|
-
if (
|
|
13585
|
-
|
|
13586
|
-
|
|
13587
|
-
|
|
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
|
|
14871
|
+
/** Previews a fill range on hover or focus without committing it. */
|
|
13591
14872
|
preview(event) {
|
|
13592
14873
|
if (this.readonlyValue) return;
|
|
13593
|
-
this.#
|
|
14874
|
+
const ordinal = this.#symbolOrdinal(event.currentTarget);
|
|
14875
|
+
if (ordinal !== null) this.#setFillRange(ordinal);
|
|
13594
14876
|
}
|
|
13595
|
-
/** Restores the fill range
|
|
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
|
|
14882
|
+
/** Arrow/Home/End/Space/Delete keyboard control, clamped without wrapping. */
|
|
13601
14883
|
onKeydown(event) {
|
|
13602
|
-
if (event.defaultPrevented) return;
|
|
13603
|
-
|
|
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 =
|
|
14891
|
+
next = current + (event.key === "ArrowRight" && rtl ? -1 : 1);
|
|
13611
14892
|
break;
|
|
13612
14893
|
case "ArrowLeft":
|
|
13613
14894
|
case "ArrowDown":
|
|
13614
|
-
next =
|
|
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.
|
|
14901
|
+
next = this.symbolTargets.length;
|
|
13621
14902
|
break;
|
|
13622
14903
|
case " ":
|
|
13623
14904
|
case "Enter":
|
|
13624
|
-
next = this.#
|
|
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.#
|
|
14917
|
+
this.#commit(next, { focus: true });
|
|
13631
14918
|
}
|
|
13632
14919
|
/**
|
|
13633
|
-
*
|
|
13634
|
-
*
|
|
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
|
-
#
|
|
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
|
|
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
|
|
14955
|
+
/** Marks the first `range` symbols with the consumer-owned fill hook. */
|
|
13661
14956
|
#setFillRange(range) {
|
|
13662
|
-
|
|
13663
|
-
|
|
13664
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
13673
|
-
this.
|
|
13674
|
-
this.element
|
|
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
|
-
|
|
13677
|
-
|
|
13678
|
-
symbol
|
|
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
|
-
|
|
13681
|
-
this.#setFillRange(value);
|
|
14979
|
+
this.#roving.setActive(-1);
|
|
13682
14980
|
}
|
|
13683
14981
|
/**
|
|
13684
|
-
*
|
|
13685
|
-
*
|
|
13686
|
-
*
|
|
13687
|
-
*
|
|
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
|
-
#
|
|
13690
|
-
|
|
13691
|
-
|
|
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
|
-
/**
|
|
13698
|
-
#
|
|
13699
|
-
const
|
|
13700
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
14328
|
-
#
|
|
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
|
-
|
|
14334
|
-
this.
|
|
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 (
|
|
14340
|
-
|
|
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(
|
|
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
|
-
|
|
14353
|
-
|
|
14354
|
-
|
|
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
|
-
|
|
14364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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", "
|
|
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
|
-
|
|
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.
|
|
16922
|
-
|
|
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.
|
|
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
|
|
16951
|
-
if (
|
|
18367
|
+
const last = this.#chipRow.lastIndex;
|
|
18368
|
+
if (last >= 0) {
|
|
16952
18369
|
event.preventDefault();
|
|
16953
|
-
this.#removeAt(
|
|
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
|
-
|
|
16959
|
-
if (buttons.length > 0) {
|
|
18375
|
+
if (this.#chipRow.length > 0) {
|
|
16960
18376
|
event.preventDefault();
|
|
16961
|
-
this.#
|
|
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.#
|
|
16995
|
-
this.#syncState();
|
|
16996
|
-
this
|
|
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.
|
|
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
|
|
17003
|
-
const label = fragment.querySelector('[data-tags-input-
|
|
17004
|
-
const button = fragment.querySelector(
|
|
17005
|
-
|
|
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
|
-
|
|
17008
|
-
button.setAttribute("aria-label",
|
|
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
|
-
/**
|
|
17013
|
-
|
|
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.#
|
|
17019
|
-
this.#syncState();
|
|
17020
|
-
this
|
|
17021
|
-
|
|
17022
|
-
|
|
17023
|
-
|
|
17024
|
-
|
|
17025
|
-
|
|
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
|
-
...
|
|
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 &&
|
|
18481
|
+
const full = this.maxValue > 0 && values.length >= this.maxValue;
|
|
17073
18482
|
this.element.toggleAttribute("data-stimeo--tags-input-full", full);
|
|
17074
|
-
|
|
17075
|
-
|
|
17076
|
-
|
|
17077
|
-
|
|
17078
|
-
|
|
17079
|
-
|
|
17080
|
-
|
|
17081
|
-
|
|
17082
|
-
|
|
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
|
-
/**
|
|
17314
|
-
#
|
|
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
|
|
18742
|
+
/** Last composed value, used to suppress duplicate user notifications. */
|
|
17319
18743
|
#lastValue = "";
|
|
17320
|
-
|
|
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
|
-
|
|
17323
|
-
|
|
17324
|
-
|
|
17325
|
-
|
|
17326
|
-
|
|
17327
|
-
|
|
17328
|
-
|
|
17329
|
-
|
|
17330
|
-
|
|
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
|
-
/**
|
|
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
|
|
18800
|
+
"[data-stimeo--time-picker-target~='segment']"
|
|
17337
18801
|
);
|
|
17338
|
-
|
|
17339
|
-
|
|
17340
|
-
|
|
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.#
|
|
18826
|
+
this.#setDisplayed(kind, this.#bounds(kind).min);
|
|
18827
|
+
this.#commitRender();
|
|
17360
18828
|
break;
|
|
17361
18829
|
case "End":
|
|
17362
18830
|
event.preventDefault();
|
|
17363
|
-
this.#
|
|
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.#
|
|
17373
|
-
this.#typeSegment = null;
|
|
18842
|
+
this.#clearTypeBuffer();
|
|
17374
18843
|
}
|
|
17375
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
18858
|
+
/** Mutates one complete action and then renders/notifies its final value once. */
|
|
17380
18859
|
#step(kind, delta) {
|
|
17381
|
-
if (kind === "meridiem") {
|
|
17382
|
-
this.#
|
|
17383
|
-
|
|
17384
|
-
|
|
17385
|
-
|
|
17386
|
-
|
|
17387
|
-
|
|
17388
|
-
|
|
17389
|
-
|
|
17390
|
-
|
|
17391
|
-
|
|
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.#
|
|
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
|
-
|
|
17428
|
-
|
|
17429
|
-
this.#
|
|
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.#
|
|
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
|
|
17439
|
-
const
|
|
17440
|
-
|
|
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
|
|
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.#
|
|
17447
|
-
|
|
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
|
-
/**
|
|
17465
|
-
#
|
|
17466
|
-
|
|
17467
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
17481
|
-
#
|
|
17482
|
-
if (
|
|
17483
|
-
|
|
17484
|
-
|
|
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
|
|
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
|
|
17837
|
-
|
|
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
|
|
17840
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
17851
|
-
if (
|
|
17852
|
-
|
|
17853
|
-
const
|
|
17854
|
-
const
|
|
17855
|
-
|
|
17856
|
-
|
|
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
|
-
|
|
19551
|
+
event.stopImmediatePropagation();
|
|
17885
19552
|
return;
|
|
17886
|
-
|
|
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.#
|
|
19575
|
+
if (destination) this.#setActive(destination, true);
|
|
17891
19576
|
}
|
|
17892
|
-
/** Applies
|
|
17893
|
-
#
|
|
17894
|
-
|
|
17895
|
-
if (!item)
|
|
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
|
-
|
|
17899
|
-
this.#setPressed(other,
|
|
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.#
|
|
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
|
|
19767
|
+
/** Reflects pressed state while distinguishing controller writes from authored morphs. */
|
|
17914
19768
|
#setPressed(item, pressed) {
|
|
17915
|
-
|
|
19769
|
+
const value = pressed ? "true" : "false";
|
|
19770
|
+
this.#internalPressedValues.set(item, value);
|
|
19771
|
+
item.setAttribute("aria-pressed", value);
|
|
17916
19772
|
}
|
|
17917
|
-
/** The
|
|
19773
|
+
/** The DOM-ordered values of every currently pressed managed item. */
|
|
17918
19774
|
#pressedValues() {
|
|
17919
|
-
return this.
|
|
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) {
|