@jsenv/navi 0.29.156 → 0.29.158

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.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, initFocusGroup, scrollIntoViewScoped, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, isTouchDrivenEvent, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
+ import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, isTouchDrivenEvent, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { chainEvent, clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
10
  import { isValidElement, h, Fragment, createContext, render, toChildArray, options, cloneElement } from "preact";
@@ -888,6 +888,157 @@ const isPlainObject$2 = obj => {
888
888
  return Object.getPrototypeOf(obj) === proto || Object.getPrototypeOf(obj) === null;
889
889
  };
890
890
 
891
+ const isSignal = (value) => {
892
+ return getSignalType(value) !== null;
893
+ };
894
+
895
+ const BRAND_SYMBOL = Symbol.for("preact-signals");
896
+ const getSignalType = (value) => {
897
+ if (!value || typeof value !== "object") {
898
+ return null;
899
+ }
900
+
901
+ if (value.brand !== BRAND_SYMBOL) {
902
+ return null;
903
+ }
904
+
905
+ if (typeof value._fn === "function") {
906
+ return "computed";
907
+ }
908
+
909
+ return "signal";
910
+ };
911
+
912
+ const MAX_ENTRIES = 5;
913
+
914
+ const stringifyForDisplay = (
915
+ value,
916
+ maxDepth = 2,
917
+ currentDepth = 0,
918
+ options = {},
919
+ ) => {
920
+ const { asFunctionArgs = false } = options;
921
+ const indent = " ".repeat(currentDepth);
922
+ const nextIndent = " ".repeat(currentDepth + 1);
923
+
924
+ if (currentDepth >= maxDepth) {
925
+ return typeof value === "object" && value !== null
926
+ ? "[Object]"
927
+ : String(value);
928
+ }
929
+
930
+ if (value === null) {
931
+ return "null";
932
+ }
933
+ if (value === undefined) {
934
+ return "undefined";
935
+ }
936
+ if (typeof value === "string") {
937
+ return `"${value}"`;
938
+ }
939
+ if (typeof value === "number" || typeof value === "boolean") {
940
+ return String(value);
941
+ }
942
+ if (typeof value === "function") {
943
+ return `[Function ${value.name || "anonymous"}]`;
944
+ }
945
+ if (value instanceof Date) {
946
+ return `Date(${value.toISOString()})`;
947
+ }
948
+ if (value instanceof RegExp) {
949
+ return value.toString();
950
+ }
951
+
952
+ if (Array.isArray(value)) {
953
+ const openBracket = asFunctionArgs ? "(" : "[";
954
+ const closeBracket = asFunctionArgs ? ")" : "]";
955
+
956
+ if (value.length === 0) return `${openBracket}${closeBracket}`;
957
+
958
+ // Display arrays with only one element on a single line
959
+ if (value.length === 1) {
960
+ const item = stringifyForDisplay(
961
+ value[0],
962
+ maxDepth,
963
+ currentDepth + 1,
964
+ // Remove asFunctionArgs for nested calls
965
+ { ...options, asFunctionArgs: false },
966
+ );
967
+ return `${openBracket}${item}${closeBracket}`;
968
+ }
969
+
970
+ if (value.length > MAX_ENTRIES) {
971
+ const preview = value
972
+ .slice(0, MAX_ENTRIES)
973
+ .map(
974
+ (v) =>
975
+ `${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
976
+ );
977
+ return `${openBracket}\n${preview.join(",\n")},\n${nextIndent}...${value.length - MAX_ENTRIES} more\n${indent}${closeBracket}`;
978
+ }
979
+
980
+ const items = value.map(
981
+ (v) =>
982
+ `${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
983
+ );
984
+ return `${openBracket}\n${items.join(",\n")}\n${indent}${closeBracket}`;
985
+ }
986
+
987
+ if (typeof value === "object") {
988
+ const signalType = getSignalType(value);
989
+ if (signalType) {
990
+ const signalValue = value.peek();
991
+ const prefix = signalType === "computed" ? "computed" : "signal";
992
+ return `${prefix}(${stringifyForDisplay(signalValue, maxDepth, currentDepth, { ...options, asFunctionArgs: false })})`;
993
+ }
994
+
995
+ const entries = Object.entries(value);
996
+ if (entries.length === 0) return "{}";
997
+
998
+ // ✅ Inclure les clés avec valeurs undefined/null
999
+ const allEntries = [];
1000
+ for (const [key, val] of entries) {
1001
+ allEntries.push([key, val]);
1002
+ }
1003
+
1004
+ // Ajouter les clés avec undefined (que Object.entries omet)
1005
+ const descriptor = Object.getOwnPropertyDescriptors(value);
1006
+ for (const [key, desc] of Object.entries(descriptor)) {
1007
+ if (desc.value === undefined && !entries.some(([k]) => k === key)) {
1008
+ allEntries.push([key, undefined]);
1009
+ }
1010
+ }
1011
+
1012
+ // Display objects with only one key on a single line
1013
+ if (allEntries.length === 1) {
1014
+ const [key, val] = allEntries[0];
1015
+ const valueStr = stringifyForDisplay(val, maxDepth, currentDepth + 1, {
1016
+ ...options,
1017
+ asFunctionArgs: false,
1018
+ });
1019
+ return `{ ${key}: ${valueStr} }`;
1020
+ }
1021
+
1022
+ if (allEntries.length > MAX_ENTRIES) {
1023
+ const preview = allEntries
1024
+ .slice(0, MAX_ENTRIES)
1025
+ .map(
1026
+ ([k, v]) =>
1027
+ `${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
1028
+ );
1029
+ return `{\n${preview.join(",\n")},\n${nextIndent}...${allEntries.length - MAX_ENTRIES} more\n${indent}}`;
1030
+ }
1031
+
1032
+ const pairs = allEntries.map(
1033
+ ([k, v]) =>
1034
+ `${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
1035
+ );
1036
+ return `{\n${pairs.join(",\n")}\n${indent}}`;
1037
+ }
1038
+
1039
+ return String(value);
1040
+ };
1041
+
891
1042
  /*
892
1043
  * Deep structural equality for arbitrary JS values — what `===` can't do but this
893
1044
  * codebase constantly needs: memoization cache keys ({ id: 1 } equal to { id: 1 }),
@@ -898,9 +1049,9 @@ const isPlainObject$2 = obj => {
898
1049
  * "wrong" for equality purposes: NaN equals NaN, Date compared by time value,
899
1050
  * cycles don't loop (a set of the pairs being compared guards circular refs),
900
1051
  * and same-type is required
901
- * before descending. Functions, and objects with nothing enumerable to compare
902
- * (a Set, a Map, an element, a URL), are equal by reference only — nothing in
903
- * them says whether two are "the same". Cheap paths run first: reference
1052
+ * before descending. Functions, signals, and objects with nothing enumerable to
1053
+ * compare (a Set, a Map, an element, a URL), are equal by reference only —
1054
+ * nothing in them says whether two are "the same". Cheap paths run first: reference
904
1055
  * equality, then the identity short-circuit below, then array length before
905
1056
  * element-by-element.
906
1057
  *
@@ -918,6 +1069,7 @@ const isPlainObject$2 = obj => {
918
1069
  * compareTwoJsValues(a, b); // true immediately, no property walk
919
1070
  */
920
1071
 
1072
+
921
1073
  // Marks objects with a conceptual identity that transcends reference equality —
922
1074
  // see the file comment. Symbol.for keeps it one shared symbol across modules.
923
1075
  const SYMBOL_IDENTITY = Symbol.for("navi_object_identity");
@@ -1060,6 +1212,15 @@ const compareTwoJsValues = (
1060
1212
  if (aIsDate) {
1061
1213
  return a.getTime() === b.getTime();
1062
1214
  }
1215
+ // A signal is a source of truth, not a value: two of them holding the same
1216
+ // thing right now say nothing about the next tick, and one may change while
1217
+ // the other does not. Their internals (the current value, a version
1218
+ // counter) are enumerable, so without this they would be walked and two
1219
+ // unrelated signals would pass for one — a params signal owned by one
1220
+ // screen would then be handed the action bound to another's.
1221
+ if (isSignal(a) || isSignal(b)) {
1222
+ return false;
1223
+ }
1063
1224
  const aKeys = Object.keys(a);
1064
1225
  const bKeys = Object.keys(b);
1065
1226
  if (aKeys.length !== bKeys.length) {
@@ -1142,159 +1303,37 @@ const debounceSignal = (
1142
1303
  clearTimeout(timeoutId);
1143
1304
  debouncedSignal.value = latestValue;
1144
1305
  };
1306
+ // Whoever reads the debounced value gets the previous one during the delay,
1307
+ // with nothing saying a newer one is on its way. This says it.
1308
+ debouncedSignal.settlingSignal = computed(() => {
1309
+ return !compareTwoJsValues(signalToDebounce.value, debouncedSignal.value);
1310
+ });
1145
1311
 
1146
1312
  return debouncedSignal;
1147
1313
  };
1148
1314
 
1149
- const isSignal = (value) => {
1150
- return getSignalType(value) !== null;
1151
- };
1152
-
1153
- const BRAND_SYMBOL = Symbol.for("preact-signals");
1154
- const getSignalType = (value) => {
1155
- if (!value || typeof value !== "object") {
1156
- return null;
1157
- }
1158
-
1159
- if (value.brand !== BRAND_SYMBOL) {
1160
- return null;
1161
- }
1162
-
1163
- if (typeof value._fn === "function") {
1164
- return "computed";
1165
- }
1166
-
1167
- return "signal";
1168
- };
1169
-
1170
- const MAX_ENTRIES = 5;
1171
-
1172
- const stringifyForDisplay = (
1173
- value,
1174
- maxDepth = 2,
1175
- currentDepth = 0,
1176
- options = {},
1177
- ) => {
1178
- const { asFunctionArgs = false } = options;
1179
- const indent = " ".repeat(currentDepth);
1180
- const nextIndent = " ".repeat(currentDepth + 1);
1181
-
1182
- if (currentDepth >= maxDepth) {
1183
- return typeof value === "object" && value !== null
1184
- ? "[Object]"
1185
- : String(value);
1186
- }
1187
-
1188
- if (value === null) {
1189
- return "null";
1190
- }
1191
- if (value === undefined) {
1192
- return "undefined";
1193
- }
1194
- if (typeof value === "string") {
1195
- return `"${value}"`;
1196
- }
1197
- if (typeof value === "number" || typeof value === "boolean") {
1198
- return String(value);
1199
- }
1200
- if (typeof value === "function") {
1201
- return `[Function ${value.name || "anonymous"}]`;
1202
- }
1203
- if (value instanceof Date) {
1204
- return `Date(${value.toISOString()})`;
1205
- }
1206
- if (value instanceof RegExp) {
1207
- return value.toString();
1208
- }
1209
-
1210
- if (Array.isArray(value)) {
1211
- const openBracket = asFunctionArgs ? "(" : "[";
1212
- const closeBracket = asFunctionArgs ? ")" : "]";
1213
-
1214
- if (value.length === 0) return `${openBracket}${closeBracket}`;
1315
+ const debouncedSignalCache = new WeakMap();
1215
1316
 
1216
- // Display arrays with only one element on a single line
1217
- if (value.length === 1) {
1218
- const item = stringifyForDisplay(
1219
- value[0],
1220
- maxDepth,
1221
- currentDepth + 1,
1222
- // Remove asFunctionArgs for nested calls
1223
- { ...options, asFunctionArgs: false },
1224
- );
1225
- return `${openBracket}${item}${closeBracket}`;
1226
- }
1227
-
1228
- if (value.length > MAX_ENTRIES) {
1229
- const preview = value
1230
- .slice(0, MAX_ENTRIES)
1231
- .map(
1232
- (v) =>
1233
- `${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
1234
- );
1235
- return `${openBracket}\n${preview.join(",\n")},\n${nextIndent}...${value.length - MAX_ENTRIES} more\n${indent}${closeBracket}`;
1236
- }
1237
-
1238
- const items = value.map(
1239
- (v) =>
1240
- `${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
1241
- );
1242
- return `${openBracket}\n${items.join(",\n")}\n${indent}${closeBracket}`;
1317
+ /**
1318
+ * The one debounced signal for a given source and delay.
1319
+ *
1320
+ * Two callers asking the same question of the same signal must wait on the same
1321
+ * answer: a debounced signal per caller is a timer per caller, and the actions
1322
+ * derived from them are then several instances of what is one request.
1323
+ */
1324
+ const getDebouncedSignal = (signalToDebounce, delay) => {
1325
+ let byDelay = debouncedSignalCache.get(signalToDebounce);
1326
+ if (!byDelay) {
1327
+ byDelay = new Map();
1328
+ debouncedSignalCache.set(signalToDebounce, byDelay);
1243
1329
  }
1244
-
1245
- if (typeof value === "object") {
1246
- const signalType = getSignalType(value);
1247
- if (signalType) {
1248
- const signalValue = value.peek();
1249
- const prefix = signalType === "computed" ? "computed" : "signal";
1250
- return `${prefix}(${stringifyForDisplay(signalValue, maxDepth, currentDepth, { ...options, asFunctionArgs: false })})`;
1251
- }
1252
-
1253
- const entries = Object.entries(value);
1254
- if (entries.length === 0) return "{}";
1255
-
1256
- // ✅ Inclure les clés avec valeurs undefined/null
1257
- const allEntries = [];
1258
- for (const [key, val] of entries) {
1259
- allEntries.push([key, val]);
1260
- }
1261
-
1262
- // Ajouter les clés avec undefined (que Object.entries omet)
1263
- const descriptor = Object.getOwnPropertyDescriptors(value);
1264
- for (const [key, desc] of Object.entries(descriptor)) {
1265
- if (desc.value === undefined && !entries.some(([k]) => k === key)) {
1266
- allEntries.push([key, undefined]);
1267
- }
1268
- }
1269
-
1270
- // Display objects with only one key on a single line
1271
- if (allEntries.length === 1) {
1272
- const [key, val] = allEntries[0];
1273
- const valueStr = stringifyForDisplay(val, maxDepth, currentDepth + 1, {
1274
- ...options,
1275
- asFunctionArgs: false,
1276
- });
1277
- return `{ ${key}: ${valueStr} }`;
1278
- }
1279
-
1280
- if (allEntries.length > MAX_ENTRIES) {
1281
- const preview = allEntries
1282
- .slice(0, MAX_ENTRIES)
1283
- .map(
1284
- ([k, v]) =>
1285
- `${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
1286
- );
1287
- return `{\n${preview.join(",\n")},\n${nextIndent}...${allEntries.length - MAX_ENTRIES} more\n${indent}}`;
1288
- }
1289
-
1290
- const pairs = allEntries.map(
1291
- ([k, v]) =>
1292
- `${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`,
1293
- );
1294
- return `{\n${pairs.join(",\n")}\n${indent}}`;
1330
+ const existing = byDelay.get(delay);
1331
+ if (existing) {
1332
+ return existing;
1295
1333
  }
1296
-
1297
- return String(value);
1334
+ const debouncedSignal = debounceSignal(signalToDebounce, { delay });
1335
+ byDelay.set(delay, debouncedSignal);
1336
+ return debouncedSignal;
1298
1337
  };
1299
1338
 
1300
1339
  /**
@@ -3145,8 +3184,11 @@ const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
3145
3184
  * - prerun actions may have no other reference yet, so
3146
3185
  * prerunProtectionRegistry pins them for a few minutes.
3147
3186
  *
3148
- * An action run never throws: failures land in errorSignal and are reported
3149
- * once, by one rule, in action_error_report.js (see the comment in onRunError).
3187
+ * A failing run rejects, and writes its error into errorSignal as well. The
3188
+ * callers that cannot take a rejection — a run started from a signal effect, a
3189
+ * control that already draws the failure — swallow it where they start it, and
3190
+ * that is where an error nobody displayed is reported once, by one rule (see
3191
+ * run_unwatched.js and action_error_report.js).
3150
3192
  *
3151
3193
  * Blast radius: this file is the substrate of routing (route_action.js,
3152
3194
  * action_run_effect.js), resources (state/rest) and every control's `action`
@@ -3157,6 +3199,10 @@ const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
3157
3199
  * routing or lists.
3158
3200
  */
3159
3201
 
3202
+ // Params waiting to settle exist only under a debounced binding; every other
3203
+ // action answers "no" so nobody has to check whether it can be asked.
3204
+ const NOT_SETTLING_SIGNAL = signal(false);
3205
+
3160
3206
  let DEBUG$2 = false;
3161
3207
  const enableDebugActions = () => {
3162
3208
  DEBUG$2 = true;
@@ -3525,16 +3571,27 @@ ${lines.join("\n")}`);
3525
3571
 
3526
3572
  const actionToRunPrivateProperties =
3527
3573
  getActionPrivateProperties(actionToPrerunOrRun);
3528
- const performRunResult = actionToRunPrivateProperties.performRun({
3529
- globalAbortSignal,
3530
- abortSignal: effectiveSignal,
3531
- reason,
3532
- event,
3533
- isPrerun,
3534
- onComplete,
3535
- onAbort,
3536
- onError,
3537
- });
3574
+ let performRunResult;
3575
+ try {
3576
+ performRunResult = actionToRunPrivateProperties.performRun({
3577
+ globalAbortSignal,
3578
+ abortSignal: effectiveSignal,
3579
+ reason,
3580
+ event,
3581
+ isPrerun,
3582
+ onComplete,
3583
+ onAbort,
3584
+ onError,
3585
+ });
3586
+ } catch (error) {
3587
+ // A synchronous callback that threw. Caught here so the update carries
3588
+ // on — the other actions of the same update have nothing to do with
3589
+ // this one — and given back further down to whoever asked for THIS
3590
+ // action, still as a throw.
3591
+ activationWeakSet.add(actionToPrerunOrRun);
3592
+ resultArray.push({ type: "sync_error", error });
3593
+ return;
3594
+ }
3538
3595
  activationWeakSet.add(actionToPrerunOrRun);
3539
3596
 
3540
3597
  if (performRunResult && typeof performRunResult.then === "function") {
@@ -3580,14 +3637,30 @@ ${lines.join("\n")}`);
3580
3637
  requestedResult = null;
3581
3638
  } else if (hasAsync) {
3582
3639
  requestedResult = Promise.all(
3583
- resultArray.map((item) =>
3584
- item.type === "sync" ? item.result : item.promise,
3585
- ),
3640
+ resultArray.map((item) => {
3641
+ if (item.type === "sync") {
3642
+ return item.result;
3643
+ }
3644
+ if (item.type === "sync_error") {
3645
+ return Promise.reject(item.error);
3646
+ }
3647
+ return item.promise;
3648
+ }),
3586
3649
  );
3587
3650
  } else {
3651
+ const itemFailed = resultArray.find((item) => item.type === "sync_error");
3652
+ if (itemFailed) {
3653
+ // Nothing of this update is left to do, so the failure can leave the way
3654
+ // it arrived: thrown, to the caller that asked for that action.
3655
+ throw itemFailed.error;
3656
+ }
3588
3657
  requestedResult = resultArray.map((item) => item.result);
3589
3658
  }
3590
3659
 
3660
+ // Settled rather than raced: a failure here is one outcome among several —
3661
+ // the other actions of the same update ran too, and "the update is over" must
3662
+ // not become "one of them failed". Waiting on this never rejects, which is
3663
+ // what lets a navigation be waited on (see via_navigation.js).
3591
3664
  const allResult = allThenableArray.length
3592
3665
  ? Promise.allSettled(allThenableArray)
3593
3666
  : null;
@@ -3816,12 +3889,53 @@ const createAction = (callback, rootOptions = {}) => {
3816
3889
  ...options,
3817
3890
  });
3818
3891
  };
3892
+ /**
3893
+ * The action instance for these params.
3894
+ *
3895
+ * @param {any} newParamsOrSignal - params, or a signal holding them (the
3896
+ * result then retargets itself as the signal changes), or an object whose
3897
+ * values may be signals.
3898
+ * @param {object} [options]
3899
+ * @param {number} [options.debounce] - milliseconds the params must stay
3900
+ * stable before the instance follows them. What it buys is a screen that
3901
+ * owns its params — a search box, a picker — asking one question instead
3902
+ * of one per keystroke, without an effect: the binding IS the delay.
3903
+ * Only for a signal; params that cannot change have nothing to settle.
3904
+ * During the delay the instance is still the previous one, holding the
3905
+ * previous answer — `paramsSettlingSignal` is what says a newer one is
3906
+ * coming, and `useAsyncData(action, { loading: true })` reads it.
3907
+ */
3819
3908
  const bindParams = (newParamsOrSignal, options = {}) => {
3909
+ const { debounce, ...optionsWithoutDebounce } = options;
3910
+ if (debounce) {
3911
+ if (!isSignal(newParamsOrSignal)) {
3912
+ console.warn(
3913
+ `bindParams({ debounce }) expects a signal, received ${typeof newParamsOrSignal}: params that cannot change have nothing to settle.`,
3914
+ );
3915
+ } else {
3916
+ // The delay lives in a signal derived from theirs, one per (signal,
3917
+ // delay): the cache below then keys on it like on any other signal,
3918
+ // so two call sites asking the same question with the same delay get
3919
+ // the one instance, and two different delays get two.
3920
+ const debouncedParamsSignal = getDebouncedSignal(
3921
+ newParamsOrSignal,
3922
+ debounce,
3923
+ );
3924
+ return bindParams(debouncedParamsSignal, {
3925
+ ...optionsWithoutDebounce,
3926
+ syncParams: debouncedParamsSignal.flush,
3927
+ paramsSettlingSignal: debouncedParamsSignal.settlingSignal,
3928
+ });
3929
+ }
3930
+ }
3820
3931
  const existingChildAction = childActionWeakMap.get(newParamsOrSignal);
3821
3932
  if (existingChildAction) {
3822
3933
  return existingChildAction;
3823
3934
  }
3824
- const childAction = _bindParams(newParamsOrSignal, options);
3935
+ const childAction = _bindParams(
3936
+ newParamsOrSignal,
3937
+ optionsWithoutDebounce,
3938
+ );
3825
3939
  childActionWeakMap.set(newParamsOrSignal, childAction);
3826
3940
  if (childAction !== action) {
3827
3941
  // binding that added nothing resolves to the action itself; it must
@@ -3964,6 +4078,7 @@ const createAction = (callback, rootOptions = {}) => {
3964
4078
  },
3965
4079
 
3966
4080
  paramsSignal,
4081
+ paramsSettlingSignal: NOT_SETTLING_SIGNAL,
3967
4082
  runningStateSignal,
3968
4083
  isPrerunSignal,
3969
4084
  valueSignal,
@@ -4154,20 +4269,31 @@ const createAction = (callback, rootOptions = {}) => {
4154
4269
  onError?.(error, { event, action, args });
4155
4270
  });
4156
4271
 
4157
- // The error is in errorSignal; from here it is the UI's, and running
4158
- // the action is not the place to decide whether the UI wants it —
4159
- // that answer does not exist yet at this instant (see
4160
- // action_error_report.js). So the run never throws: it settles with
4161
- // the error as its value, and being displayed or not is constated
4162
- // afterwards, in one place, by one rule.
4163
4272
  if (onError) {
4164
4273
  // Asking for the error IS taking it.
4165
4274
  markErrorAsDisplayedBy(error, "onError");
4166
4275
  }
4167
- reportErrorIfNobodyDisplaysIt(error, { action });
4276
+ // Handed back, not delivered: failRun decides whether this leaves as
4277
+ // a throw or as a rejection, because only the caller below knows
4278
+ // which of the two this run was. The error is in errorSignal either
4279
+ // way — a screen reads it there, whether or not anything was waiting.
4168
4280
  return error;
4169
4281
  };
4170
4282
 
4283
+ // A failure leaves the run the way it came: thrown when the callback
4284
+ // threw synchronously — code that knows this action is synchronous
4285
+ // writes a plain `try/catch` around the call and has to find it there —
4286
+ // and rejected when the run was asynchronous, since the throw below
4287
+ // then happens inside a `then`. An abort is neither: it settles with
4288
+ // its reason as its value, the way it always has.
4289
+ const failRun = (error) => {
4290
+ const errorSettled = onRunError(error);
4291
+ if (runningStateSignal.peek() === ABORTED) {
4292
+ return errorSettled;
4293
+ }
4294
+ throw errorSettled;
4295
+ };
4296
+
4171
4297
  try {
4172
4298
  const thenableArray = [];
4173
4299
  const callbackResult = callback(...args);
@@ -4205,12 +4331,12 @@ const createAction = (callback, rootOptions = {}) => {
4205
4331
  }
4206
4332
  return Promise.all(thenableArray).then(() => {
4207
4333
  if (rejected) {
4208
- return onRunError(rejectedValue);
4334
+ return failRun(rejectedValue);
4209
4335
  }
4210
4336
  return onRunEnd();
4211
4337
  });
4212
4338
  } catch (e) {
4213
- return onRunError(e);
4339
+ return failRun(e);
4214
4340
  }
4215
4341
  };
4216
4342
 
@@ -4297,6 +4423,7 @@ const createActionProxyFromSignal = (
4297
4423
  inheritData = true,
4298
4424
  onChange,
4299
4425
  syncParams,
4426
+ paramsSettlingSignal = NOT_SETTLING_SIGNAL,
4300
4427
  } = {},
4301
4428
  ) => {
4302
4429
  const actionTargetChangeCallbackSet = new Set();
@@ -4473,6 +4600,7 @@ const createActionProxyFromSignal = (
4473
4600
  meta: action.meta,
4474
4601
 
4475
4602
  paramsSignal: proxyParamsSignal,
4603
+ paramsSettlingSignal,
4476
4604
  isPrerunSignal: proxySignal("isPrerunSignal", "isPrerun"),
4477
4605
  runningStateSignal: proxySignal("runningStateSignal", "runningState"),
4478
4606
  errorSignal: proxySignal("errorSignal", "error"),
@@ -4618,6 +4746,46 @@ const isPlainObject = (obj) => {
4618
4746
  );
4619
4747
  };
4620
4748
 
4749
+ /**
4750
+ * Starts a run navi has nobody to await.
4751
+ *
4752
+ * A failing run does not stay quiet: it throws when the callback threw
4753
+ * synchronously, and rejects when it was asynchronous (see failRun in
4754
+ * actions.js). That is what makes `ACTION(params)` behave like any other call
4755
+ * that can fail — and code that lets such a failure go gets the runtime's own
4756
+ * unhandled-error report, which is the right answer for it.
4757
+ *
4758
+ * Some runs have no such caller by construction — one started from a signal
4759
+ * effect because its params changed, one whose failure the control that started
4760
+ * it already draws, a routing whose result every caller drops. Their failure
4761
+ * would be an anonymous unhandled one, naming the machinery instead of what
4762
+ * failed. They start here, which is why the run itself is passed rather than
4763
+ * its result: both deliveries have to be taken.
4764
+ *
4765
+ * And taking them is what obliges this place to ask the other question: did
4766
+ * anything ever display this error? The answer does not exist yet at this
4767
+ * instant — a route action fails before the page that shows it exists — so it is
4768
+ * asked with a deadline, once, in action_error_report.js.
4769
+ *
4770
+ * The error is never hidden: it stays in the action's `errorSignal`, which is
4771
+ * where a screen reads it.
4772
+ */
4773
+ const runUnwatched = (startRun) => {
4774
+ let result;
4775
+ try {
4776
+ result = startRun();
4777
+ } catch (error) {
4778
+ reportErrorIfNobodyDisplaysIt(error);
4779
+ return undefined;
4780
+ }
4781
+ if (result && typeof result.catch === "function") {
4782
+ result.catch((error) => {
4783
+ reportErrorIfNobodyDisplaysIt(error);
4784
+ });
4785
+ }
4786
+ return result;
4787
+ };
4788
+
4621
4789
  /**
4622
4790
  * Reactively runs an action whenever the params derived from signals change.
4623
4791
  *
@@ -4629,7 +4797,9 @@ const isPlainObject = (obj) => {
4629
4797
  * @param {number} [options.debounce] - When set, the action is only run once the derived params
4630
4798
  * have been stable for this many milliseconds. Useful to avoid firing a backend call on every
4631
4799
  * keystroke: set `debounce: 500` and the request is sent only after the user stops interacting
4632
- * with the filters for 500 ms.
4800
+ * with the filters for 500 ms. During that delay the action still holds the previous answer;
4801
+ * `action.paramsSettlingSignal` is what says a newer one is coming, and
4802
+ * `useAsyncData(action, { loading: true })` reads it.
4633
4803
  *
4634
4804
  * Example — auto-refresh a result list while the user tweaks filters:
4635
4805
  * ```js
@@ -4645,12 +4815,6 @@ const isPlainObject = (obj) => {
4645
4815
  // one — in dev, an error overlay thrown over a page that is already saying what
4646
4816
  // went wrong. Nothing is lost by dropping it: the failure is held by the action
4647
4817
  // itself, and whoever reads it (useAsyncData, <Button action>) is what shows it.
4648
- const runUnwatched = (result) => {
4649
- if (result && typeof result.catch === "function") {
4650
- result.catch(() => {});
4651
- }
4652
- };
4653
-
4654
4818
  const actionRunEffect = (
4655
4819
  action,
4656
4820
  deriveActionParamsFromSignals,
@@ -4659,7 +4823,7 @@ const actionRunEffect = (
4659
4823
  if (typeof action === "function") {
4660
4824
  action = createAction(action);
4661
4825
  }
4662
- let actionParamsSignal = computed(() => {
4826
+ const actionParamsSignal = computed(() => {
4663
4827
  const params = deriveActionParamsFromSignals();
4664
4828
  action.debug(
4665
4829
  `Derived params for action "${action}": ${stringifyForDisplay(params)}`,
@@ -4677,14 +4841,8 @@ const actionRunEffect = (
4677
4841
  }
4678
4842
  return params;
4679
4843
  });
4680
- if (debounce) {
4681
- actionParamsSignal = debounceSignal(actionParamsSignal, {
4682
- delay: debounce,
4683
- });
4684
- }
4685
-
4686
4844
  const actionRunnedByThisEffect = action.bindParams(actionParamsSignal, {
4687
- syncParams: debounce ? actionParamsSignal.flush : undefined,
4845
+ debounce,
4688
4846
  onChange: (actionTarget, actionTargetPrevious, { explicitRunIntent }) => {
4689
4847
  if (explicitRunIntent) {
4690
4848
  // The caller already issued an explicit run/rerun/prerun/reset/abort —
@@ -4700,7 +4858,9 @@ const actionRunEffect = (
4700
4858
  // falsy params, don't run
4701
4859
  return;
4702
4860
  }
4703
- runUnwatched(actionTarget.run({ reason: "truthy params first run" }));
4861
+ runUnwatched(() =>
4862
+ actionTarget.run({ reason: "truthy params first run" }),
4863
+ );
4704
4864
  return;
4705
4865
  }
4706
4866
 
@@ -4717,18 +4877,18 @@ const actionRunEffect = (
4717
4877
  }
4718
4878
  if (!actionTargetPrevious.params) {
4719
4879
  // coming from falsy-params state: action may already be cached, avoid unnecessary rerun
4720
- runUnwatched(
4880
+ runUnwatched(() =>
4721
4881
  actionTarget.run({ reason: "params restored from falsy state" }),
4722
4882
  );
4723
4883
  } else {
4724
- runUnwatched(actionTarget.rerun({ reason: "params modified" }));
4884
+ runUnwatched(() => actionTarget.rerun({ reason: "params modified" }));
4725
4885
  }
4726
4886
  }
4727
4887
  },
4728
4888
  ...options,
4729
4889
  });
4730
4890
  if (actionParamsSignal.peek()) {
4731
- runUnwatched(
4891
+ runUnwatched(() =>
4732
4892
  actionRunnedByThisEffect.run({ reason: "initial truthy params" }),
4733
4893
  );
4734
4894
  }
@@ -5610,6 +5770,16 @@ const ActionRequesterContext = createContext();
5610
5770
  * Whichever mode, the affordance's own handler runs from inside its own gate
5611
5771
  * rather than from the DOM: a caller's `onClick` fires before any of this, and
5612
5772
  * would go off from a button drawn greyed.
5773
+ *
5774
+ * These two answer about the GESTURE. Whose VALUE an element carries is a third
5775
+ * question, and `standalone` is where it is answered (see useUIStateController).
5776
+ * They are said separately because they genuinely come apart: a door that holds
5777
+ * no value of its own can still write into the control around it — it is
5778
+ * `standalone` and must still shut when that control is read-only — and an
5779
+ * affordance can claim a press without ever being a field. Something that is
5780
+ * its own on all three counts says all three (a diskette filing a name into the
5781
+ * reader's own address book: `selfInteractions="click"`,
5782
+ * `whenSelfInteractionsBlocked="ignore"`, `standalone`).
5613
5783
  */
5614
5784
 
5615
5785
 
@@ -10162,6 +10332,35 @@ const watchActionCompletion = (element, dispatchAction) => {
10162
10332
  };
10163
10333
  };
10164
10334
 
10335
+ /**
10336
+ * What follows the control's OWN action: it runs once that action has
10337
+ * succeeded, and not at all otherwise.
10338
+ *
10339
+ * The outcome arrives in one of three shapes and each decides differently:
10340
+ * - the gate turned the request down (`result === false`): nothing happened,
10341
+ * so nothing follows;
10342
+ * - the action is running: what follows waits for it, and an error or an abort
10343
+ * drops it — whatever the action left in front of the user stays;
10344
+ * - it is already settled, or never started at all. A control with nothing to
10345
+ * run leaves no outcome behind, and no outcome means nothing went wrong.
10346
+ */
10347
+ const runWhenActionSucceeded = (completion, callback) => {
10348
+ if (completion.result === false) {
10349
+ return;
10350
+ }
10351
+ if (completion.isRunning) {
10352
+ completion.whenSucceeded(callback);
10353
+ return;
10354
+ }
10355
+ let succeeded = true;
10356
+ completion.whenSettled(({ error, aborted }) => {
10357
+ succeeded = !error && !aborted;
10358
+ });
10359
+ if (succeeded) {
10360
+ callback();
10361
+ }
10362
+ };
10363
+
10165
10364
  const tryActionAfterInteractionAllowed = (
10166
10365
  element,
10167
10366
  {
@@ -12822,7 +13021,10 @@ const useExecuteAction = (
12822
13021
  });
12823
13022
  };
12824
13023
 
12825
- return runAction();
13024
+ // The control is already holding the failure — the error side effect
13025
+ // above drew the callout, or threw it at the boundary — so nothing here
13026
+ // is waiting on the rejection.
13027
+ return runUnwatched(runAction);
12826
13028
  },
12827
13029
  [elementRef, errorEffect],
12828
13030
  );
@@ -15174,8 +15376,12 @@ const createResource = (
15174
15376
  idKey,
15175
15377
  uniqueKeys,
15176
15378
 
15379
+ // Reactive reads, not hooks: they subscribe the render that calls them to
15380
+ // the store's array signal. Nothing hook-shaped inside, so a loop or a
15381
+ // condition is fine. `useAllByIds` skips the ids the store does not hold.
15177
15382
  useArray: () => store.arraySignal.value,
15178
15383
  useById: (id) => store.select(idKey, id),
15384
+ useAllByIds: (idArray) => store.selectAll(idArray),
15179
15385
 
15180
15386
  withParams: undefined,
15181
15387
  one: undefined,
@@ -17332,15 +17538,11 @@ const useActionAsyncData = (action, {
17332
17538
  // the wait is the ordinary one: suspended into <Loading>, or drawn by the
17333
17539
  // component under `loading: true`, exactly as for data someone else ran.
17334
17540
  if (run && action.runningStateSignal.peek() === IDLE && action.paramsSignal.peek() !== undefined) {
17335
- const runResult = action.run({
17541
+ // Nothing waits on this run: the failure is held by the action, and this
17542
+ // hook is what reads it back.
17543
+ runUnwatched(() => action.run({
17336
17544
  reason: "useAsyncData({ run: true })"
17337
- });
17338
- // Nobody awaits this run, and a rejection nobody awaits is an unhandled
17339
- // one — in dev, an overlay over a component already saying what failed.
17340
- // The failure is held by the action, and this hook is what reads it.
17341
- if (runResult && typeof runResult.catch === "function") {
17342
- runResult.catch(() => {});
17343
- }
17545
+ }));
17344
17546
  }
17345
17547
 
17346
17548
  // Use peek() instead of .value to avoid subscribing this component to the signal.
@@ -17374,14 +17576,34 @@ const useActionAsyncData = (action, {
17374
17576
  }
17375
17577
  setTick(n => n + 1);
17376
17578
  });
17579
+ // A debounced binding waits before it retargets, so nothing above changes
17580
+ // while the delay runs — but what is on screen is already out of date.
17581
+ let settlingNotificationIsInitial = true;
17582
+ const unsubscribeFromParamsSettling = action.paramsSettlingSignal.subscribe(() => {
17583
+ if (settlingNotificationIsInitial) {
17584
+ settlingNotificationIsInitial = false;
17585
+ return;
17586
+ }
17587
+ setTick(n => n + 1);
17588
+ });
17377
17589
  return () => {
17378
17590
  unsubscribeFromRunningState();
17379
17591
  unsubscribeFromData();
17592
+ unsubscribeFromParamsSettling();
17380
17593
  };
17381
17594
  // Bound to the action, not to the mount: params given as a plain object
17382
17595
  // make another action instance, and the component would otherwise stay
17383
17596
  // subscribed to the state of the one it no longer reads.
17384
17597
  }, [action]);
17598
+
17599
+ // The params moved and the run has not started: under a debounce the action
17600
+ // is still the previous one, COMPLETED with the previous answer. There is no
17601
+ // run to await yet, so nothing can be suspended on — but a component drawing
17602
+ // its own wait can say the answer on screen is on its way out, which is the
17603
+ // whole reason it asked for `loading`.
17604
+ if (loadingEffect === "use" && runningState !== FAILED && action.paramsSettlingSignal.peek()) {
17605
+ return [action.dataSignal.peek(), true, undefined];
17606
+ }
17385
17607
  if (runningState === COMPLETED) {
17386
17608
  return [action.dataSignal.peek(), false, undefined];
17387
17609
  }
@@ -17431,12 +17653,6 @@ const useActionAsyncData = (action, {
17431
17653
  throw actionError;
17432
17654
  }
17433
17655
 
17434
- // RUNNING with loadingEffect: "use" — return stale data + loading flag, no suspend
17435
- if (loadingEffect === "use" && runningState === RUNNING) {
17436
- const staleData = action.dataSignal.peek();
17437
- return [staleData, true, undefined];
17438
- }
17439
-
17440
17656
  // An action without params has nothing to run and no one to start it: this is
17441
17657
  // where a route action lands when its params getter returns false. It is not a
17442
17658
  // load in progress, so there is nothing to wait for — suspending here would
@@ -17446,6 +17662,17 @@ const useActionAsyncData = (action, {
17446
17662
  return [action.dataSignal.peek(), false, undefined];
17447
17663
  }
17448
17664
 
17665
+ // `loading: true` says the component draws its own wait, so this hook never
17666
+ // suspends: running, or holding params nobody has started yet — a debounced
17667
+ // binding that just retargeted, an aborted run, a run this very render is
17668
+ // about to issue. Either way what is on screen is not the current answer,
17669
+ // which is what `loading` says. Suspending would not draw a spinner: it swaps
17670
+ // the subtree for the boundary, and everything under it remounts — a dialog
17671
+ // open inside it is then closed, by a hook the caller told to stay out of it.
17672
+ if (loadingEffect === "use") {
17673
+ return [action.dataSignal.peek(), true, undefined];
17674
+ }
17675
+
17449
17676
  // IDLE or RUNNING with loadingEffect: "delegate" — suspend
17450
17677
  const reason = runningState === RUNNING ? "loading" : "idle";
17451
17678
  loadingRef.current = {
@@ -26222,18 +26449,12 @@ const setupBrowserIntegrationViaHistory = ({
26222
26449
  // something at the first announcement has a definite place to give it back.
26223
26450
  publishBeforeRouting({ url, ...options });
26224
26451
  try {
26225
- const routingResult = applyRoutingTask(url, options);
26226
- if (routingResult && typeof routingResult.then === "function") {
26227
- // Every caller below drops this value — a click handler has nothing to
26228
- // do with what the routing returns — so a rejection here would become
26229
- // an anonymous unhandled one, pointing at the navigation rather than at
26230
- // what failed. It goes to the single place that knows what to do with
26231
- // an error nobody displays (see action_error_report.js).
26232
- routingResult.catch((e) => {
26233
- reportErrorIfNobodyDisplaysIt(e);
26234
- });
26235
- }
26236
- return routingResult;
26452
+ // Every caller below drops this value — a click handler has nothing to do
26453
+ // with what the routing returns — so a rejection here would become an
26454
+ // anonymous unhandled one, pointing at the navigation rather than at what
26455
+ // failed. Taken, and passed to the one place that knows what to do with
26456
+ // an error nobody displays (see run_unwatched.js).
26457
+ return runUnwatched(() => applyRoutingTask(url, options));
26237
26458
  } finally {
26238
26459
  publishAfterRouting({ url, ...options });
26239
26460
  }
@@ -28105,8 +28326,10 @@ const RouteTransitionArea = ({
28105
28326
  * @param {object} to - same forms. Going from `from` to `to` plays forward,
28106
28327
  * the reverse plays back — unless the reverse is written as a relation of
28107
28328
  * its own, which then owns that way (a movement of its own, or `"none"` for
28108
- * a plain cut). A change between two pages no relation was written for plays
28109
- * nothing.
28329
+ * a plain cut). Written with the SAME movement it owns that way all the
28330
+ * same, so both crossings play forward and the pair can never say "back":
28331
+ * that pair is warned about. A change between two pages no relation was
28332
+ * written for plays nothing.
28110
28333
  * @param {string|{type?: string, duration?: number|string}} [transition] -
28111
28334
  * what plays: a type name, or `{ type, duration }` to also say how long
28112
28335
  * (`--navi-route-transition-duration` says it for everyone otherwise).
@@ -28155,6 +28378,7 @@ const defineRouteTransition = (from, to, transition) => {
28155
28378
  type,
28156
28379
  duration
28157
28380
  };
28381
+ warnAboutBothWaysWritten(relation);
28158
28382
  relations.push(relation);
28159
28383
  rebuildWatcher();
28160
28384
  return () => {
@@ -28749,6 +28973,38 @@ const currentPageIndex$1 = pages => {
28749
28973
  }
28750
28974
  return currentIndex;
28751
28975
  };
28976
+
28977
+ // Both ways of a pair written with the SAME movement. The exact way travelled
28978
+ // is searched before any reverse (see findRelation), so each way then finds
28979
+ // its own relation and BOTH play forward: the pair loses the one thing a
28980
+ // direction is for. That is not a resolution to fix — it is what makes
28981
+ // reciprocity a default — it is a definition with no reading under which it is
28982
+ // what the author meant, so it is said here, where both relations are known.
28983
+ // Narrow on purpose: a different type is the asymmetry the reverse exists for,
28984
+ // "none" is silence said out loud, and a different duration is a way out taken
28985
+ // slower.
28986
+ const warnAboutBothWaysWritten = ({
28987
+ from,
28988
+ to,
28989
+ type,
28990
+ duration
28991
+ }) => {
28992
+ const reverse = relations.find(candidate => samePage$1(candidate.from, to) && samePage$1(candidate.to, from));
28993
+ if (!reverse) {
28994
+ return;
28995
+ }
28996
+ if (reverse.type !== type || reverse.duration !== duration) {
28997
+ return;
28998
+ }
28999
+ if (type === "none") {
29000
+ return;
29001
+ }
29002
+ // The one already written is named first: that is the order the application
29003
+ // wrote them in, and the order it will find them in to fix them.
29004
+ const written = `${describePage(reverse.from)} → ${describePage(reverse.to)}`;
29005
+ const added = `${describePage(from)} → ${describePage(to)}`;
29006
+ warnOnce(`both-ways-written:${written}|${added}`, `${written} and ${added} are both written with the same movement, so BOTH crossings play forward and this pair can never say "back" — the back button included. A relation written for the exact way travelled wins over being the reverse of another (see findRelation), which is what makes reciprocity the default: write the way back only to give it a DIFFERENT movement, or "none" to silence it. A single crossing that walks the map backwards says so on itself instead: <Link routeTransition={{ direction: "forward" }}>, or navTo(url, { routeTransition: { direction: "forward" } }).`);
29007
+ };
28752
29008
  const warnPagesBothCurrent = (pageKept, pageIgnored) => {
28753
29009
  const kept = describePage(pageKept);
28754
29010
  const ignored = describePage(pageIgnored);
@@ -32613,20 +32869,21 @@ const ParentUIStateControllerContext = createContext();
32613
32869
  */
32614
32870
  const useUIStateController = (
32615
32871
  props,
32616
- {
32617
- controlInfo,
32618
- syncDomState,
32619
- uiActionInternal,
32620
- persists,
32621
- allowNameless = false,
32622
- } = {},
32872
+ { controlInfo, syncDomState, uiActionInternal, persists, standalone } = {},
32623
32873
  ) => {
32624
32874
  const debugPopup = useDebugPopup();
32625
32875
  const debugInteraction = useDebugInteraction();
32626
32876
  const debugUIState = useDebugUIState();
32627
32877
  const debugFocus = useDebugFocus();
32628
32878
 
32629
- const parentUIStateController = useContext(ParentUIStateControllerContext);
32879
+ // The whole of `standalone`: with no parent to register into, the effect
32880
+ // below has nobody to call, and every question a group asks about its
32881
+ // children is asked about someone else. Read here rather than filtered by
32882
+ // each group shape in turn, so a control that answers for itself is out of
32883
+ // the value on the way up AND out of the reset, the validation cascade and
32884
+ // the distribution on the way down.
32885
+ const parentFromContext = useContext(ParentUIStateControllerContext);
32886
+ const parentUIStateController = standalone ? undefined : parentFromContext;
32630
32887
  const formContext = useContext(FormContext);
32631
32888
  if (persists === undefined && formContext) {
32632
32889
  persists = true;
@@ -32700,7 +32957,6 @@ const useUIStateController = (
32700
32957
  parentUIStateController,
32701
32958
  parentUiStateSignalHolder,
32702
32959
  isProxy,
32703
- allowNameless,
32704
32960
  emptyUIState,
32705
32961
  // Set here too, not only in `update` below: a control rendered once and
32706
32962
  // never re-rendered would otherwise never say whether it was GIVEN a
@@ -33366,18 +33622,14 @@ const GROUP_DEFAULTS = {
33366
33622
  single: {
33367
33623
  // The same exclusions canRegisterAsFacadeChild already makes below (the
33368
33624
  // picker façade asked the very same question: which child IS the value).
33369
- // Buttons and links never hold one, and neither does a control that
33370
- // declared itself nameless. A control *carrying* navi-list is the search
33371
- // box driving some other list — not the list itself, which stays a
33625
+ // Buttons and links never hold one. A control *carrying* navi-list is the
33626
+ // search box driving some other list — not the list itself, which stays a
33372
33627
  // perfectly good single value here (one item, or the array a multiple list
33373
33628
  // exposes). Excluding the searcher is what leaves the list alone.
33374
33629
  childControlFilter: (child) => {
33375
33630
  if (child.controlType === "button" || child.controlType === "link") {
33376
33631
  return false;
33377
33632
  }
33378
- if (child.allowNameless) {
33379
- return false;
33380
- }
33381
33633
  if (child.props?.["navi-list"]) {
33382
33634
  return false;
33383
33635
  }
@@ -33402,7 +33654,7 @@ const GROUP_DEFAULTS = {
33402
33654
  aggregateChildStates: (children) => {
33403
33655
  const groupValues = {};
33404
33656
  for (const child of children) {
33405
- const { name, allowNameless, emptyUIState } = child;
33657
+ const { name, emptyUIState } = child;
33406
33658
  // A control holding nothing writes its own empty, not a hole: the key
33407
33659
  // is in the object either way, and what is read from it keeps the shape
33408
33660
  // the reader was promised (see resolveEmptyUIState).
@@ -33411,13 +33663,6 @@ const GROUP_DEFAULTS = {
33411
33663
  ? emptyUIState
33412
33664
  : child.uiState;
33413
33665
  if (!name) {
33414
- if (allowNameless) {
33415
- // A control that says it is not a field is not one, whatever it
33416
- // holds: a picker used as a door holds the shape its popup draws,
33417
- // and merging that in would put the popup's keys in the object as
33418
- // if the door had been a group.
33419
- continue;
33420
- }
33421
33666
  // A nameless GROUP is a grouping, not a value: it exists to hold its
33422
33667
  // children together (a WheelGroup sharing navigation, a fieldset-ish
33423
33668
  // cluster) without claiming a key of its own, so what it holds is
@@ -33530,6 +33775,7 @@ const useUIGroupStateController = (
33530
33775
  uiActionInternal,
33531
33776
  allowCapture = false,
33532
33777
  cascadeValidationToChildren = false,
33778
+ standalone,
33533
33779
  },
33534
33780
  ) => {
33535
33781
  const debugPopup = useDebugPopup();
@@ -33565,7 +33811,11 @@ const useUIGroupStateController = (
33565
33811
  `Either use a known controlType/stateType or provide aggregateChildStates and distributeChildUIState explicitly.`,
33566
33812
  );
33567
33813
  }
33568
- const parentUIStateController = useContext(ParentUIStateControllerContext);
33814
+ // See the leaf controller's own `standalone` above: the same opt-out, and a
33815
+ // group taking it also takes its whole subtree out — the controls inside it
33816
+ // register into IT, and it registers into nobody.
33817
+ const parentFromContext = useContext(ParentUIStateControllerContext);
33818
+ const parentUIStateController = standalone ? undefined : parentFromContext;
33569
33819
  // A bound signal seeds the group the way `defaultValue` does — uncontrolled,
33570
33820
  // with the signal's current value as what it starts on. Write-back is handled
33571
33821
  // by applyState's own boundSignal; the read half is below: children are
@@ -34336,7 +34586,6 @@ const EMPTY_OBJECT = {};
34336
34586
  */
34337
34587
  const useUIFacadeStateController = (props, realUIStateController) => {
34338
34588
  const firstChildControllerRef = useRef(null);
34339
- const namelessChildSetRef = useRef(new Set());
34340
34589
  const updatingRef = useRef(false);
34341
34590
  const debugPopup = useDebugPopup();
34342
34591
  const debugInteraction = useDebugInteraction();
@@ -34364,16 +34613,9 @@ const useUIFacadeStateController = (props, realUIStateController) => {
34364
34613
  if (childController.isProxy) {
34365
34614
  return false;
34366
34615
  }
34367
- if (childController.allowNameless) {
34368
- // A control saying it is not a field is not the one the picker talks
34369
- // to: the search box above the list, the "select all" switch beside
34370
- // it. It is there to help find the answer, not to be it.
34371
- namelessChildSetRef.current.add(childController);
34372
- return false;
34373
- }
34374
34616
  if (childController.props["navi-list"]) {
34375
- // Controls with navi-list act as standalone list navigators and should
34376
- // not be treated as the picker's synced child.
34617
+ // Controls with navi-list act as list navigators driving some other
34618
+ // list, not as the picker's synced child.
34377
34619
  return false;
34378
34620
  }
34379
34621
  return true;
@@ -34391,10 +34633,6 @@ const useUIFacadeStateController = (props, realUIStateController) => {
34391
34633
  }
34392
34634
  const child = firstChildControllerRef.current;
34393
34635
  if (!child) {
34394
- warnPopupHasNothingButNamelessControls(
34395
- props,
34396
- namelessChildSetRef.current,
34397
- );
34398
34636
  return;
34399
34637
  }
34400
34638
  updatingRef.current = true;
@@ -34429,7 +34667,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
34429
34667
  `A picker talks to ONE control: the first one receives the picker's whole value and is the only one read back, ` +
34430
34668
  `so this one is neither filled nor collected. ` +
34431
34669
  `A popup holding several values needs one group around them — wrap them in a <ControlGroup>, name each control inside it, and give the picker type="object". ` +
34432
- `A control that is there to FIND the answer rather than be it (a search box, a "select all") says so with allowNameless and steps out of the way.`,
34670
+ `A control that answers for itself rather than for the picker — a search box, a list acting on every touch — says so with standalone and steps out of the way.`,
34433
34671
  child,
34434
34672
  );
34435
34673
  } else {
@@ -34556,11 +34794,6 @@ const useUIFacadeStateController = (props, realUIStateController) => {
34556
34794
 
34557
34795
  const describePicker = (props) =>
34558
34796
  `<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
34559
- const warnPopupHasNothingButNamelessControls = (props, namelessChildSet) => {
34560
- {
34561
- return;
34562
- }
34563
- };
34564
34797
 
34565
34798
  /**
34566
34799
  * Returns true when `e` should trigger parent notification (child → parent bubbling).
@@ -34833,19 +35066,19 @@ const ControlgroupChildrenWrapper = ({
34833
35066
  */
34834
35067
  const useControlProps = (props, {
34835
35068
  controlType,
34836
- allowNameless: allowNamelessByDefault,
34837
35069
  persists,
34838
35070
  uiActionInternal
34839
35071
  }) => {
34840
35072
  const debugUIState = useDebugUIState();
34841
35073
  const debugAction = useDebugAction();
34842
35074
 
34843
- // A control that is not a field: it opens something, it goes somewhere, and
34844
- // the group around it must expect no value from it — no name, and no warning
34845
- // about the missing name. Buttons and links say so from inside navi; the prop
34846
- // is how a control used as a door says the same thing from the outside.
34847
- const allowNameless = props.allowNameless ?? allowNamelessByDefault;
34848
- delete props.allowNameless;
35075
+ // This control answers for itself: whatever group it sits in expects nothing
35076
+ // from it and gives it nothing back. See useUIStateController, which is where
35077
+ // the whole of it happens.
35078
+ const {
35079
+ standalone
35080
+ } = props;
35081
+ delete props.standalone;
34849
35082
  const idDefault = useId();
34850
35083
  const controlId = useContext(ControlIdContext);
34851
35084
  props.id = props.id || controlId || idDefault;
@@ -34901,7 +35134,7 @@ const useControlProps = (props, {
34901
35134
  const uiStateController = useUIStateController(props, {
34902
35135
  controlInfo,
34903
35136
  syncDomState,
34904
- allowNameless,
35137
+ standalone,
34905
35138
  persists,
34906
35139
  uiActionInternal
34907
35140
  });
@@ -35100,28 +35333,7 @@ const useControlProps = (props, {
35100
35333
  if (!deferredCommand) {
35101
35334
  return;
35102
35335
  }
35103
- if (completion.result === false) {
35104
- // The action was turned down (a failing constraint, a gate saying
35105
- // no) — nothing happened, so nothing follows.
35106
- return;
35107
- }
35108
- if (completion.isRunning) {
35109
- completion.whenSucceeded(deferredCommand);
35110
- return;
35111
- }
35112
- // Synchronous: already settled, and how it ended still decides. An
35113
- // action that never started (nothing to run) leaves no outcome, and
35114
- // the command runs as it always did.
35115
- let succeeded = true;
35116
- completion.whenSettled(({
35117
- error,
35118
- aborted
35119
- }) => {
35120
- succeeded = !error && !aborted;
35121
- });
35122
- if (succeeded) {
35123
- deferredCommand();
35124
- }
35336
+ runWhenActionSucceeded(completion, deferredCommand);
35125
35337
  };
35126
35338
  return {
35127
35339
  keyDown: keyDownDefault,
@@ -35882,8 +36094,10 @@ const useControlgroupProps = (props, {
35882
36094
  cascadeValidationToChildren = false
35883
36095
  }) => {
35884
36096
  const {
35885
- action
36097
+ action,
36098
+ standalone
35886
36099
  } = props;
36100
+ delete props.standalone;
35887
36101
  const uiGroupStateController = useUIGroupStateController(props, controlType, {
35888
36102
  stateType,
35889
36103
  childControlFilter,
@@ -35893,7 +36107,8 @@ const useControlgroupProps = (props, {
35893
36107
  wantRequesterButtonState,
35894
36108
  uiActionInternal,
35895
36109
  allowCapture,
35896
- cascadeValidationToChildren
36110
+ cascadeValidationToChildren,
36111
+ standalone
35897
36112
  });
35898
36113
  const [boundAction] = useActionBoundToOneParam(action, uiGroupStateController.uiStateSignal);
35899
36114
  // Mirror single-input behaviour: a controlled value with nobody listening
@@ -40261,8 +40476,7 @@ const LinkPlain = props => {
40261
40476
  selectionController
40262
40477
  });
40263
40478
  const [controlRootProps, controlHostProps] = useControlProps(props, {
40264
- controlType: "link",
40265
- allowNameless: true
40479
+ controlType: "link"
40266
40480
  });
40267
40481
  const {
40268
40482
  basePseudoState
@@ -40694,6 +40908,10 @@ let navCount = 0;
40694
40908
  // Worn by a nav of routes while a route movement between two of its tabs is
40695
40909
  // pictured (see markIndicatorTakesPart, and the CSS below for what it decides).
40696
40910
  const BETWEEN_TABS_ATTRIBUTE = "data-nav-between-tabs";
40911
+ // What one notch of a wheel that counts in LINES is worth on a row that counts
40912
+ // in pixels. A rough line, deliberately: nothing here needs to agree with a
40913
+ // paragraph, only to move the row about as far as a notch moves a page.
40914
+ const WHEEL_LINE_SIZE = 16;
40697
40915
  const css$W = /* css */`@layer navi {
40698
40916
  .navi_nav {
40699
40917
  --nav-border: none;
@@ -40777,6 +40995,14 @@ const css$W = /* css */`@layer navi {
40777
40995
  justify-content: stretch;
40778
40996
  display: flex;
40779
40997
 
40998
+ &[data-scrollable] {
40999
+ max-width: 100%;
41000
+ }
41001
+
41002
+ &[data-scrollable][data-vertical] {
41003
+ max-height: 100%;
41004
+ }
41005
+
40780
41006
  & .navi_link {
40781
41007
  user-select: none;
40782
41008
  --x-nav-child-border-radius: calc(var(--nav-border-radius) - var(--nav-padding));
@@ -40945,6 +41171,39 @@ const Nav = ({
40945
41171
  const slideContainerElementRef = useRef(null);
40946
41172
  const indicatorPosition = slideContainer ? positionOfCurrentIndicator(currentIndicator, vertical) : null;
40947
41173
 
41174
+ // Which tab of this row is the one being shown: the slide the container says
41175
+ // it is on, or, for a row of routes, the link the browser is on.
41176
+ const getCurrentTabElement = () => {
41177
+ const navElement = navRef.current;
41178
+ if (!navElement) {
41179
+ return null;
41180
+ }
41181
+ const containerElement = slideContainerElementRef.current;
41182
+ if (!containerElement) {
41183
+ return navElement.querySelector("[data-href-current]");
41184
+ }
41185
+ const currentArea = containerElement.getAttribute(SLIDE_CURRENT_ATTRIBUTE);
41186
+ if (currentArea === null) {
41187
+ return null;
41188
+ }
41189
+ return navElement.querySelector(`[data-slide-target="${CSS.escape(currentArea)}"]`);
41190
+ };
41191
+ // …and which one the picture leans on while it is not on the current one:
41192
+ // the slide a finger is pulling in, the slide an animation is leaving. There
41193
+ // is one only while a travel is playing.
41194
+ const getTowardTabElement = () => {
41195
+ const navElement = navRef.current;
41196
+ const containerElement = slideContainerElementRef.current;
41197
+ if (!navElement || !containerElement) {
41198
+ return null;
41199
+ }
41200
+ const towardArea = containerElement.getAttribute(SLIDE_TOWARD_ATTRIBUTE);
41201
+ if (towardArea === null) {
41202
+ return null;
41203
+ }
41204
+ return navElement.querySelector(`[data-slide-target="${CSS.escape(towardArea)}"]`);
41205
+ };
41206
+
40948
41207
  // Where the trait is and where it is headed, as four numbers of pixels the
40949
41208
  // CSS above interpolates between (see the .navi_nav_indicator rules). Written
40950
41209
  // by hand rather than rendered: it is read off the row as it stands, and the
@@ -40958,8 +41217,7 @@ const Nav = ({
40958
41217
  }
40959
41218
  const tabElements = Array.from(navElement.querySelectorAll("[data-slide-target]"));
40960
41219
  const areaOf = tabElement => tabElement.getAttribute("data-slide-target");
40961
- const currentArea = containerElement.getAttribute("data-slide-current");
40962
- const currentIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === currentArea);
41220
+ const currentIndex = tabElements.indexOf(getCurrentTabElement());
40963
41221
  if (currentIndex === -1) {
40964
41222
  // On a slide no tab in this row names: there is no tab to sit under.
40965
41223
  navElement.removeAttribute("data-nav-indicator-measured");
@@ -41001,6 +41259,226 @@ const Nav = ({
41001
41259
  // about the row as it is now.
41002
41260
  const paintIndicatorGeometryRef = useRef(null);
41003
41261
  paintIndicatorGeometryRef.current = paintIndicatorGeometry;
41262
+
41263
+ // A row of tabs wider than the box holding it scrolls (the caller asks for
41264
+ // that with overflowX) — and then saying which tab one is on is not enough,
41265
+ // that tab must be somewhere one can see. The trait needs nothing here: it
41266
+ // is placed with offsetLeft, inside the row, so it rides along.
41267
+ // The ROW is what gets scrolled, never the tab: element.scrollIntoView()
41268
+ // walks every scrolling ancestor it finds, and a tab row lives in sheets
41269
+ // that sit on pages that scroll — bringing a tab into sight must move the
41270
+ // row and nothing else.
41271
+ const tabInSightRef = useRef(null);
41272
+ const keepCurrentTabInSight = () => {
41273
+ const navElement = navRef.current;
41274
+ const currentTabElement = getCurrentTabElement();
41275
+ // Brought into sight once, and then left where it is: the row scrolls, so
41276
+ // the user may have pushed it elsewhere since to read the other tabs, and
41277
+ // a render that changed none of this has no business taking that back.
41278
+ if (currentTabElement === tabInSightRef.current) {
41279
+ return;
41280
+ }
41281
+ tabInSightRef.current = currentTabElement;
41282
+ if (!currentTabElement) {
41283
+ return;
41284
+ }
41285
+ scrollIntoViewScoped(currentTabElement, {
41286
+ container: navElement
41287
+ });
41288
+ };
41289
+
41290
+ // …and while a travel is playing the row gets there AT THE PACE OF THE
41291
+ // TRAVEL, off the very number the trait is drawn from: the container
41292
+ // publishes how far between the two slides the picture stands
41293
+ // (--slide-travel-progress, written on this row — see followerElements), and
41294
+ // the row is scrolled the same fraction of the way between the two tabs. The
41295
+ // slides gliding while the tabs jump at the end is one movement told twice.
41296
+ //
41297
+ // Read frame by frame, because that number is nobody's news: under a finger
41298
+ // the container writes it per frame with no announcement (a write is not a
41299
+ // change), and for a travel that was asked for it is the browser that
41300
+ // animates it. So the row samples it, exactly as the trait does — except that
41301
+ // a scroll offset is not a thing CSS can be handed.
41302
+ const travelScrollRef = useRef(null);
41303
+ const syncTabScroll = ({
41304
+ afterAFrame
41305
+ } = {}) => {
41306
+ const navElement = navRef.current;
41307
+ if (!navElement) {
41308
+ return;
41309
+ }
41310
+ const currentTabElement = getCurrentTabElement();
41311
+ const towardTabElement = getTowardTabElement();
41312
+ if (!currentTabElement || !towardTabElement) {
41313
+ travelScrollRef.current = null;
41314
+ // Nothing is travelling — but a travel that was ASKED for is announced a
41315
+ // breath after the slide it lands on, so a row that lands the moment the
41316
+ // current tab changes lands ahead of the movement it should have ridden,
41317
+ // and the slides then glide towards a row already there. Giving it a
41318
+ // frame costs nothing: the slides have not moved either.
41319
+ // Only for a row of slides, and only once — a row of routes has no travel
41320
+ // to ride, and the second look is the one that lands.
41321
+ if (slideContainer && !afterAFrame && tabInSightRef.current && tabInSightRef.current !== currentTabElement) {
41322
+ scheduleTabScrollSync({
41323
+ nextFrame: true
41324
+ });
41325
+ return;
41326
+ }
41327
+ // Nowhere between: the row lands where it belongs.
41328
+ keepCurrentTabInSight();
41329
+ return;
41330
+ }
41331
+ let travel = travelScrollRef.current;
41332
+ if (!travel || travel.from !== currentTabElement || travel.toward !== towardTabElement) {
41333
+ // The two ends, measured once for this travel: measuring them again on a
41334
+ // row that has since moved would be measuring from the answer.
41335
+ const fromOffsets = getScrollIntoViewScopedOffsets(currentTabElement, {
41336
+ container: navElement
41337
+ });
41338
+ const towardOffsets = getScrollIntoViewScopedOffsets(towardTabElement, {
41339
+ container: navElement
41340
+ });
41341
+ // Signed the way the container counts: +1 when the picture leans on a
41342
+ // slide sitting BEFORE the current one, -1 when it sits after — the same
41343
+ // reading paintIndicatorGeometry does of the same number.
41344
+ const sign = currentTabElement.compareDocumentPosition(towardTabElement) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
41345
+ travel = {
41346
+ from: currentTabElement,
41347
+ toward: towardTabElement,
41348
+ left: fromOffsets.left,
41349
+ top: fromOffsets.top,
41350
+ leftDelta: (towardOffsets.left - fromOffsets.left) * sign,
41351
+ topDelta: (towardOffsets.top - fromOffsets.top) * sign
41352
+ };
41353
+ travelScrollRef.current = travel;
41354
+ }
41355
+ const progress = parseFloat(getComputedStyle(navElement).getPropertyValue("--slide-travel-progress")) || 0;
41356
+ navElement.scrollTo({
41357
+ left: travel.left + progress * travel.leftDelta,
41358
+ top: travel.top + progress * travel.topDelta,
41359
+ // The row is being driven frame by frame: a caller who asked for smooth
41360
+ // scrolling asked it of a scroll that lands, not of one that is held.
41361
+ behavior: "instant"
41362
+ });
41363
+ scheduleTabScrollSync({
41364
+ nextFrame: true
41365
+ });
41366
+ };
41367
+ const syncTabScrollRef = useRef(null);
41368
+ syncTabScrollRef.current = syncTabScroll;
41369
+
41370
+ // When to look: on the next frame while a travel is being followed, and
41371
+ // otherwise at the end of the current task. A MICROTASK and not a frame,
41372
+ // because the two are told apart in the same breath — a travel that was
41373
+ // asked for announces the slide it lands on before it announces the one it
41374
+ // is leaving, so a row settling straight away would jump to the destination
41375
+ // and only then be asked to travel there. And it is still before the paint,
41376
+ // which a frame is not: a row that opens on a far tab must be scrolled
41377
+ // already the first time it is seen.
41378
+ const syncScheduledRef = useRef(false);
41379
+ const syncFrameRef = useRef(0);
41380
+ const scheduleTabScrollSync = ({
41381
+ nextFrame
41382
+ } = {}) => {
41383
+ if (syncScheduledRef.current) {
41384
+ return;
41385
+ }
41386
+ syncScheduledRef.current = true;
41387
+ const run = () => {
41388
+ syncScheduledRef.current = false;
41389
+ syncFrameRef.current = 0;
41390
+ syncTabScrollRef.current({
41391
+ afterAFrame: nextFrame
41392
+ });
41393
+ };
41394
+ if (nextFrame) {
41395
+ syncFrameRef.current = requestAnimationFrame(run);
41396
+ } else {
41397
+ queueMicrotask(run);
41398
+ }
41399
+ };
41400
+ const scheduleTabScrollSyncRef = useRef(null);
41401
+ scheduleTabScrollSyncRef.current = scheduleTabScrollSync;
41402
+ useLayoutEffect(() => {
41403
+ return () => {
41404
+ cancelAnimationFrame(syncFrameRef.current);
41405
+ };
41406
+ }, []);
41407
+
41408
+ // The row watches its own size and reads itself again when it changes — a
41409
+ // badge count, a font that just arrived, a window resized: what was written
41410
+ // is pixels, where the trait sits and how far the row is scrolled, and
41411
+ // pixels measured on a row of another width are wrong. A row that had no
41412
+ // width at all is the same story with a sharper edge: inside a closed
41413
+ // <dialog> everything measures 0, and the current tab does not change when
41414
+ // the dialog opens, so nothing else would ever look again.
41415
+ // A scroll takes no size from anything, so this stays quiet while the row is
41416
+ // being pushed around by hand.
41417
+ useLayoutEffect(() => {
41418
+ const sizeObserver = new ResizeObserver(() => {
41419
+ paintIndicatorGeometryRef.current();
41420
+ // What was brought into sight was brought there for another row.
41421
+ tabInSightRef.current = null;
41422
+ scheduleTabScrollSyncRef.current();
41423
+ });
41424
+ sizeObserver.observe(navRef.current);
41425
+ return () => {
41426
+ sizeObserver.disconnect();
41427
+ };
41428
+ }, []);
41429
+
41430
+ // A row that scrolls sideways, and a mouse that only turns one way. A
41431
+ // vertical wheel is left to the vertical axis, which a row of tabs does not
41432
+ // have: the push goes past the row to the page, and the tabs stay where they
41433
+ // were — a row a trackpad can push around and a mouse cannot reach at all.
41434
+ // Up and further along are the same wish here, so the push moves the row.
41435
+ // Only for a row that scrolls on ONE axis: one that scrolls the way the
41436
+ // wheel turns already has the answer it wants, and taking the push would be
41437
+ // answering it twice.
41438
+ useLayoutEffect(() => {
41439
+ const navElement = navRef.current;
41440
+ const onWheel = wheelEvent => {
41441
+ // The burst is already somebody else's — a box travelling under the
41442
+ // pointer, a wheel picker — and stays theirs until it dies out, wherever
41443
+ // the pointer has drifted since (see wheel_gesture.js).
41444
+ if (wheelGestureIsTakenFrom(navElement)) {
41445
+ return;
41446
+ }
41447
+ const {
41448
+ deltaX,
41449
+ deltaY
41450
+ } = wheelEvent;
41451
+ if (Math.abs(deltaY) <= Math.abs(deltaX)) {
41452
+ // Sideways already: the browser knows where to put it.
41453
+ return;
41454
+ }
41455
+ const scrollableWidth = navElement.scrollWidth - navElement.clientWidth;
41456
+ const scrollableHeight = navElement.scrollHeight - navElement.clientHeight;
41457
+ if (scrollableWidth <= 0 || scrollableHeight > 0) {
41458
+ return;
41459
+ }
41460
+ // Not every wheel speaks in pixels: some count lines, some count pages.
41461
+ const notch = wheelEvent.deltaMode === 1 ? WHEEL_LINE_SIZE : wheelEvent.deltaMode === 2 ? navElement.clientWidth : 1;
41462
+ const scrollLeftBefore = navElement.scrollLeft;
41463
+ const scrollLeftWanted = Math.max(0, Math.min(scrollableWidth, scrollLeftBefore + deltaY * notch));
41464
+ if (scrollLeftWanted === scrollLeftBefore) {
41465
+ // The row is at its end and the hand is still pushing: what is left
41466
+ // over is the page's, exactly as it would have been without any of
41467
+ // this.
41468
+ return;
41469
+ }
41470
+ wheelEvent.preventDefault();
41471
+ claimWheelGesture(navElement);
41472
+ navElement.scrollLeft = scrollLeftWanted;
41473
+ };
41474
+ navElement.addEventListener("wheel", onWheel, {
41475
+ passive: false
41476
+ });
41477
+ return () => {
41478
+ navElement.removeEventListener("wheel", onWheel);
41479
+ releaseWheelGesture(navElement);
41480
+ };
41481
+ }, []);
41004
41482
  useLayoutEffect(() => {
41005
41483
  if (!slideContainer) {
41006
41484
  return undefined;
@@ -41014,6 +41492,7 @@ const Nav = ({
41014
41492
  const readContainer = () => {
41015
41493
  setCurrentSlideArea(containerElement.getAttribute(SLIDE_CURRENT_ATTRIBUTE) ?? undefined);
41016
41494
  paintIndicatorGeometryRef.current();
41495
+ scheduleTabScrollSyncRef.current();
41017
41496
  };
41018
41497
  readContainer();
41019
41498
  // Where one is and what the picture leans on are written on the container,
@@ -41023,16 +41502,8 @@ const Nav = ({
41023
41502
  // because a write is not a change — the attribute the trait follows is
41024
41503
  // written on every frame of a gesture with the same value in it.
41025
41504
  containerElement.addEventListener(SLIDE_STATE_EVENT, readContainer);
41026
- // A row whose tabs changed width — a badge count, a font that just
41027
- // arrived, a window resized — is measured again: what was written is
41028
- // pixels, and pixels go stale.
41029
- const sizeObserver = new ResizeObserver(() => {
41030
- paintIndicatorGeometryRef.current();
41031
- });
41032
- sizeObserver.observe(navRef.current);
41033
41505
  return () => {
41034
41506
  containerElement.removeEventListener(SLIDE_STATE_EVENT, readContainer);
41035
- sizeObserver.disconnect();
41036
41507
  slideContainerElementRef.current = null;
41037
41508
  };
41038
41509
  }, [slideContainer]);
@@ -41041,6 +41512,7 @@ const Nav = ({
41041
41512
  // and no observer above watches this row's own children.
41042
41513
  useLayoutEffect(() => {
41043
41514
  paintIndicatorGeometry();
41515
+ scheduleTabScrollSync();
41044
41516
  });
41045
41517
 
41046
41518
  // Whether the bar keeps its name for the route movement about to be pictured
@@ -43708,7 +44180,7 @@ const Expandable = props => {
43708
44180
  }
43709
44181
  if (hasAction) {
43710
44182
  if (nextOpen) {
43711
- effectiveAction.run();
44183
+ runUnwatched(() => effectiveAction.run());
43712
44184
  } else {
43713
44185
  effectiveAction.abort();
43714
44186
  }
@@ -43859,7 +44331,7 @@ const Expandable = props => {
43859
44331
  // Mounted already open: the content is visible, its data is due.
43860
44332
  useEffect(() => {
43861
44333
  if (openedRef.current && hasAction) {
43862
- effectiveAction.run();
44334
+ runUnwatched(() => effectiveAction.run());
43863
44335
  }
43864
44336
  }, []);
43865
44337
  const onRootKeyDown = keyboardEvent => {
@@ -44675,8 +45147,7 @@ const ButtonUI = props => {
44675
45147
  loadingOutline = true
44676
45148
  } = props;
44677
45149
  const [buttonControlRootProps, buttonControlHostProps, controlChildrenWrapperProps] = useControlProps(props, {
44678
- controlType: "button",
44679
- allowNameless: true
45150
+ controlType: "button"
44680
45151
  });
44681
45152
  const {
44682
45153
  basePseudoState,
@@ -49740,30 +50211,18 @@ const Form = props => {
49740
50211
  // is a different component: same group, no <form> element and none of the
49741
50212
  // browser machinery that comes with it.
49742
50213
  const isNested = Boolean(useContext(FormContext));
49743
- const form = isNested ? jsx(FormNested, {
50214
+ return isNested ? jsx(FormNested, {
49744
50215
  ...props
49745
50216
  }) : jsx(FormControl, {
49746
50217
  ...props
49747
50218
  });
49748
- if (props.standalone) {
49749
- // Nothing above to register with: the group hooks read the parent from
49750
- // this context, so emptying it here is the whole opt-out.
49751
- return jsx(ParentUIStateControllerContext.Provider, {
49752
- value: undefined,
49753
- children: form
49754
- });
49755
- }
49756
- return form;
49757
50219
  };
49758
50220
 
49759
50221
  // What both forms are made of: one group, one context for what is inside it.
49760
- // standalone is read by Form above and never goes further — least of all to the
49761
- // DOM.
49762
50222
  const useFormGroup = props => {
49763
50223
  const propsForGroup = {
49764
50224
  ...props
49765
50225
  };
49766
- delete propsForGroup.standalone;
49767
50226
  delete propsForGroup.canSendWhileUnchanged;
49768
50227
  delete propsForGroup.pristineKey;
49769
50228
  // Not the generic control `command`, which a control triggers on its own ui
@@ -58653,9 +59112,16 @@ const PickerCustomResolver = props => {
58653
59112
  if (circle) {
58654
59113
  props.variant = "icon";
58655
59114
  }
58656
- // A door, never a field (see `allowNameless`): the form around it expects
58657
- // no value from it, and no name.
58658
- props.allowNameless = true;
59115
+ // A door, never a field (see `standalone`): the form around it expects no
59116
+ // value from it. So the trigger answers to "button" — it is pressed to read
59117
+ // something, never typed into — the same thing a confirm picker says
59118
+ // through `picksNothing` (see picker.jsx). Said as the role here rather
59119
+ // than through that prop: `picksNothing` also takes the right slot away,
59120
+ // and the status icon a callout trigger draws sits in it.
59121
+ props.standalone = true;
59122
+ if (props.role === undefined) {
59123
+ props.role = "button";
59124
+ }
58659
59125
  // A word in a sentence asks for a plain tooltip — no icon in the callout,
58660
59126
  // no status color; an icon one presses is the callout's own status icon,
58661
59127
  // and says "info" like the callout it opens.
@@ -59494,7 +59960,8 @@ installImportMetaCssBuild(import.meta);/**
59494
59960
  *
59495
59961
  * Yes is said with `--navi-confirm` (see commands.js). The popup closes on it,
59496
59962
  * and only then does the picker do what it was standing in for — run its
59497
- * `action`, or trigger its `command` — so the work runs on the trigger, where
59963
+ * `action`, trigger its `command`, or both, the command following the action
59964
+ * and only if it succeeded — so the work runs on the trigger, where
59498
59965
  * the loading state and the error callout are drawn, and where the user is
59499
59966
  * looking again. Anything else that closes the popup (the cancel button,
59500
59967
  * Escape, a click outside) is no.
@@ -59556,21 +60023,31 @@ const PickerConfirmResolver = props => {
59556
60023
  // trigger.
59557
60024
  const onConfirm = confirmEvent => {
59558
60025
  const inputEl = getPickerInput(ref.current);
59559
- if (action !== undefined) {
59560
- dispatchRequestAction(inputEl, {
59561
- event: confirmEvent,
59562
- name: "confirm"
59563
- });
60026
+ const runCommand = () => {
60027
+ triggerNaviCommand(inputEl, command, confirmEvent);
60028
+ };
60029
+ if (action === undefined) {
60030
+ if (command) {
60031
+ runCommand();
60032
+ }
59564
60033
  return;
59565
60034
  }
60035
+ // Both props on one trigger means "do this, then go there": the action runs
60036
+ // and the command follows it, only if it worked — a delete that fails must
60037
+ // not send the user to the list it did not leave. Same rule a `<Button
60038
+ // action command>` has (see onButtonInteractionAllowed in control_hooks).
60039
+ const completion = watchActionCompletion(findControlHost(inputEl) || inputEl, () => dispatchRequestAction(inputEl, {
60040
+ event: confirmEvent,
60041
+ name: "confirm"
60042
+ }));
59566
60043
  if (command) {
59567
- triggerNaviCommand(inputEl, command, confirmEvent);
60044
+ runWhenActionSucceeded(completion, runCommand);
59568
60045
  }
59569
60046
  };
59570
60047
  return jsx(Next, {
59571
60048
  ...props,
59572
60049
  type: "navi_js",
59573
- allowNameless: true
60050
+ standalone: true
59574
60051
  // No chevron, no clear cross, whatever variant the trigger is drawn in:
59575
60052
  // both announce a value, and a question holds none.
59576
60053
  ,
@@ -61970,6 +62447,7 @@ const ListFirstResolver = props => {
61970
62447
  * deselectable?: boolean,
61971
62448
  * maxLength?: number,
61972
62449
  * maxLengthGuard?: number,
62450
+ * standalone?: boolean,
61973
62451
  * action?: (value: any) => void,
61974
62452
  * uiAction?: (value: any) => void,
61975
62453
  * popover?: boolean,
@@ -62145,6 +62623,12 @@ const ListFirstResolver = props => {
62145
62623
  * taking — and `uiAction` is not called. The selected ones stay takeable
62146
62624
  * back, so a selection that arrived too long can always be brought back
62147
62625
  * under the limit. Implies `maxLength` for validity.
62626
+ * @param {boolean} [props.standalone]
62627
+ * This list answers for itself: it does not register with the control group
62628
+ * or picker around it, so its selection stays out of that value and nothing
62629
+ * coming down — a distributed value, a reset — reaches it. What a popup whose
62630
+ * one answer is spread over several lists says, so that none of them is taken
62631
+ * for the answer itself.
62148
62632
  */
62149
62633
  const List = createComponentResolver([ListFirstResolver, ListSelectableResolver, ListUI]);
62150
62634
  const ListContent = ({
@@ -65911,8 +66395,7 @@ const BadgeButtonUI = props => {
65911
66395
  const defaultRef = useRef();
65912
66396
  props.ref = props.ref || defaultRef;
65913
66397
  const [buttonRootProps, buttonHostProps] = useControlProps(props, {
65914
- controlType: "button",
65915
- allowNameless: true
66398
+ controlType: "button"
65916
66399
  });
65917
66400
  return jsx(Text, {
65918
66401
  className: "navi_badge_button",
@@ -67364,6 +67847,11 @@ const PickerButton = props => {
67364
67847
  // confirm resolver (see picker_confirm.jsx), which is the picker that is
67365
67848
  // one — a caller whose popup is a menu of actions says it for themselves.
67366
67849
  picksNothing,
67850
+ // Where the caller's own role goes: onto the host, beside the name (see
67851
+ // control_context.js) — a role and a name on two different elements name
67852
+ // nothing at all. Left unsaid, a picker that picks nothing takes "button"
67853
+ // (see PickerInput below).
67854
+ role,
67367
67855
  readOnly,
67368
67856
  error
67369
67857
  } = props;
@@ -67418,6 +67906,21 @@ const PickerButton = props => {
67418
67906
  // clearing being a modification like any other.
67419
67907
  const interactive = !basePseudoState[":disabled"] && !basePseudoState[":read-only"] && !loading;
67420
67908
  usePickerErrorCallout(uiStateController, error);
67909
+ // What the picker really is, said on the element that holds the name and
67910
+ // takes the focus (the host input below): a picker that picks nothing is a
67911
+ // door — its popup is a question (type="confirm") or something to read
67912
+ // (mode="callout", which says it for itself) — and a door is pressed. So it
67913
+ // answers to "button" rather than to the "textbox" its <input> would
67914
+ // otherwise be, which is both what a screen reader announces and what
67915
+ // getByRole("button", { name }) finds. A picker holding a value stays a
67916
+ // textbox: it does hold text.
67917
+ const hostRole = role ?? (picksNothing ? "button" : undefined);
67918
+ // And what that button is called, when the caller wrote it as a drawing
67919
+ // rather than as a word: a door draws no value, so what sits in its value
67920
+ // slot IS its label — the `ui` a caller gave it, or its placeholder. Skipped
67921
+ // the moment the caller names it themselves (aria-label/aria-labelledby),
67922
+ // which is the only way to name a door drawn as an icon.
67923
+ const hostLabelId = hostRole === "button" && inputProps["aria-label"] === undefined && inputProps["aria-labelledby"] === undefined && variant !== "headless" && ui !== "default" ? `${inputProps.id}_picker_label` : undefined;
67421
67924
  // What the picker knows about itself, for the pieces it does not place: the
67422
67925
  // drawings of its value (Picker.UI.*), and the affordances a caller may put
67423
67926
  // in their own `ui` (Picker.Clear) as much as the ones it puts in its slot.
@@ -67464,6 +67967,7 @@ const PickerButton = props => {
67464
67967
  basePseudoState: basePseudoState,
67465
67968
  styleCSSVars: PickerStyleCSSVars,
67466
67969
  variant: undefined,
67970
+ role: undefined,
67467
67971
  rightSlotIcon: undefined,
67468
67972
  rightSlotIconSize: undefined,
67469
67973
  rightSlot: undefined,
@@ -67512,6 +68016,8 @@ const PickerButton = props => {
67512
68016
  color: "var(--picker-loader-color)",
67513
68017
  inset: -2
67514
68018
  }), jsx(PickerInput, {
68019
+ role: hostRole,
68020
+ "aria-labelledby": hostLabelId,
67515
68021
  tabIndex: variant === "headless" ? -1 : undefined,
67516
68022
  "aria-hidden": variant === "headless" ? "true" : undefined,
67517
68023
  ...inputProps,
@@ -67587,6 +68093,10 @@ const PickerButton = props => {
67587
68093
  }
67588
68094
  }), variant === "headless" || ui === "default" ? null : jsx(Text, {
67589
68095
  className: "navi_picker_value"
68096
+ // Only when it names the trigger (see hostLabelId above).
68097
+ ,
68098
+
68099
+ id: hostLabelId
67590
68100
  // Tells the caller's own drawing of the control from the value
67591
68101
  // the picker draws itself, so each is written on its own line
67592
68102
  // (see .navi_picker_value in the CSS above).
@@ -70549,7 +71059,7 @@ const SplitButton = props => {
70549
71059
  }), jsx(Picker, {
70550
71060
  id: menuId,
70551
71061
  variant: "headless",
70552
- allowNameless: true,
71062
+ standalone: true,
70553
71063
  anchor: rootRef,
70554
71064
  ...popupProps,
70555
71065
  readOnly: readOnly,