@jsenv/navi 0.29.171 → 0.29.173

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.
@@ -1489,6 +1489,11 @@ naviI18n.addAll({
1489
1489
  fr: "Cette action attend une modification.",
1490
1490
  en: "This action is waiting for a change.",
1491
1491
  },
1492
+ // parallelGuard: the surface already has as many runs in flight as it allows
1493
+ "constraint.readonly.parallel_guard": {
1494
+ fr: "[max] action[s] déjà en cours, attendez qu'une se termine.",
1495
+ en: "[max] action[s] already in progress, wait for one to finish.",
1496
+ },
1492
1497
  "constraint.readonly.network_policy": {
1493
1498
  fr: "Hors ligne : ça ne peut pas partir.",
1494
1499
  en: "Offline: this cannot be sent.",
@@ -3301,15 +3306,23 @@ const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
3301
3306
  * resolves priorities between the four operation sets (reset > rerun > run >
3302
3307
  * prerun) and performs them.
3303
3308
  *
3304
- * Memory design (the surprising part): nothing here keeps actions alive.
3305
- * Child actions are held through ephemerons (createJsValueWeakMap) so a child
3306
- * and its params are garbage-collected together, running actions live in
3307
- * iterable *weak* sets, and property/signal mirroring uses weakEffect. Two
3309
+ * Memory design (the surprising part): by default nothing here keeps actions
3310
+ * alive. Child actions are held through ephemerons (createJsValueWeakMap) so a
3311
+ * child and its params are garbage-collected together, running actions live in
3312
+ * iterable *weak* sets, and property/signal mirroring uses weakEffect. Three
3308
3313
  * consequences to be aware of:
3309
3314
  * - an action can exist in several places only if everyone shares the same
3310
3315
  * instance (the caches above are what makes lookups return it);
3311
3316
  * - prerun actions may have no other reference yet, so
3312
- * prerunProtectionRegistry pins them for a few minutes.
3317
+ * prerunProtectionRegistry pins them until they answer;
3318
+ * - an answer outlives the screen that asked for it only where the question
3319
+ * declared `keep` (createKeptAnswerRegistry); everything else is asked again.
3320
+ *
3321
+ * What makes that design fragile: reading an action's signals from inside an
3322
+ * effect subscribes that effect to them, and the root action never dies — so
3323
+ * the effect, and everything its closure holds, never dies either. Stringifying
3324
+ * an action for a debug message is the easy way to do it by accident, which is
3325
+ * why toString peeks.
3313
3326
  *
3314
3327
  * A failing run rejects, and writes its error into errorSignal as well. The
3315
3328
  * callers that cannot take a rejection — a run started from a signal effect, a
@@ -3424,6 +3437,58 @@ const prerunProtectionRegistry = (() => {
3424
3437
  };
3425
3438
  })();
3426
3439
 
3440
+ /**
3441
+ * The answers a root action keeps once the screen that asked for them is gone.
3442
+ *
3443
+ * An action holds its answer for exactly as long as something references it, so
3444
+ * without this the lifetime of an answer is whatever the garbage collector
3445
+ * decides: coming back to a screen re-asks the same question, or does not,
3446
+ * depending on when a collection happened to run.
3447
+ *
3448
+ * Age is read when someone looks an answer up, never on a timer. A screen
3449
+ * nobody is on must not talk to the network, and an answer expiring under a
3450
+ * screen someone IS on would empty it mid-read.
3451
+ */
3452
+ const createKeptAnswerRegistry = ({ ms, max }) => {
3453
+ // insertion order is recency order: looking an answer up re-inserts it, so
3454
+ // the first key is always the least recently used one
3455
+ const answeredAtMap = new Map();
3456
+
3457
+ return {
3458
+ keep: (action) => {
3459
+ answeredAtMap.delete(action);
3460
+ answeredAtMap.set(action, Date.now());
3461
+ if (max === undefined) {
3462
+ return;
3463
+ }
3464
+ while (answeredAtMap.size > max) {
3465
+ const [leastRecentlyUsed] = answeredAtMap.keys();
3466
+ answeredAtMap.delete(leastRecentlyUsed);
3467
+ }
3468
+ },
3469
+
3470
+ drop: (action) => {
3471
+ answeredAtMap.delete(action);
3472
+ },
3473
+
3474
+ touch: (action) => {
3475
+ const answeredAt = answeredAtMap.get(action);
3476
+ if (answeredAt === undefined) {
3477
+ return;
3478
+ }
3479
+ answeredAtMap.delete(action);
3480
+ if (ms !== undefined && Date.now() - answeredAt > ms) {
3481
+ // reset rather than drop the instance: equal params must keep giving
3482
+ // the same action, otherwise whoever still holds this one would be
3483
+ // reading a different instance than whoever asks for it next.
3484
+ action.reset({ reason: "kept answer expired" });
3485
+ return;
3486
+ }
3487
+ answeredAtMap.set(action, answeredAt);
3488
+ },
3489
+ };
3490
+ };
3491
+
3427
3492
  const formatActionSet = (actionSet, prefix = "") => {
3428
3493
  let message = prefix;
3429
3494
  for (const action of actionSet) {
@@ -3823,12 +3888,38 @@ const mergeActionParams = (currentParams, newParams) => {
3823
3888
  };
3824
3889
 
3825
3890
  const actionWeakMap = new WeakMap();
3891
+ /**
3892
+ * Wraps an async callback into an action: a question, its answer, and the
3893
+ * signals saying where that answer is.
3894
+ *
3895
+ * @param {Function} callback
3896
+ * @param {object} [rootOptions]
3897
+ * @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
3898
+ * answer stays good once it has landed. Without it an answer lives exactly as
3899
+ * long as something references it: the screen that asked goes away, and
3900
+ * coming back asks the same question again at a moment nothing in the code
3901
+ * decides.
3902
+ *
3903
+ * `ms` counts from the moment the answer landed; `max` caps how many answers
3904
+ * are held at once, the least recently looked-at going first. Age is read
3905
+ * when someone looks, never on a timer — a screen nobody is on must not talk
3906
+ * to the network — and an expired answer is `reset()`, so the question goes
3907
+ * back out through whoever asks for it next.
3908
+ *
3909
+ * It is declared here rather than on each binding because the answer to "how
3910
+ * long is this still true?" belongs to the question: a court list is good for
3911
+ * the session, a count of people watching grows on its own and is worth a few
3912
+ * minutes, a payment status is worth nothing at all.
3913
+ */
3826
3914
  const createAction = (callback, rootOptions = {}) => {
3827
3915
  const existing = actionWeakMap.get(callback);
3828
3916
  if (existing) {
3829
3917
  return existing;
3830
3918
  }
3831
3919
 
3920
+ const keptAnswerRegistry = rootOptions.keep
3921
+ ? createKeptAnswerRegistry(rootOptions.keep)
3922
+ : null;
3832
3923
  let rootAction;
3833
3924
 
3834
3925
  const createActionCore = (options, { parentAction } = {}) => {
@@ -4060,6 +4151,9 @@ const createAction = (callback, rootOptions = {}) => {
4060
4151
  }
4061
4152
  const existingChildAction = childActionWeakMap.get(newParamsOrSignal);
4062
4153
  if (existingChildAction) {
4154
+ if (keptAnswerRegistry) {
4155
+ keptAnswerRegistry.touch(existingChildAction);
4156
+ }
4063
4157
  return existingChildAction;
4064
4158
  }
4065
4159
  const childAction = _bindParams(
@@ -4198,7 +4292,10 @@ const createAction = (callback, rootOptions = {}) => {
4198
4292
  paramsSignal.value = nextParams;
4199
4293
  return true;
4200
4294
  },
4201
- toString: () => action.callSource,
4295
+ // peek: an action gets stringified into debug messages built inside
4296
+ // effects, and subscribing there would keep those effects — and everything
4297
+ // their closures hold — alive for as long as the action itself.
4298
+ toString: () => actionCallSourceSignal.peek(),
4202
4299
  meta,
4203
4300
  debug: (...args) => {
4204
4301
  if (!meta.debug && !DEBUG$2) {
@@ -4362,6 +4459,9 @@ const createAction = (callback, rootOptions = {}) => {
4362
4459
  onComplete?.(data, action);
4363
4460
  completeSideEffectCleanup = completeSideEffect?.(action);
4364
4461
  });
4462
+ if (keptAnswerRegistry) {
4463
+ keptAnswerRegistry.keep(action);
4464
+ }
4365
4465
  if (DEBUG$2) {
4366
4466
  console.log(`"${action}": completed`);
4367
4467
  }
@@ -4477,6 +4577,9 @@ const createAction = (callback, rootOptions = {}) => {
4477
4577
  }
4478
4578
 
4479
4579
  prerunProtectionRegistry.unprotect(action);
4580
+ if (keptAnswerRegistry) {
4581
+ keptAnswerRegistry.drop(action);
4582
+ }
4480
4583
 
4481
4584
  if (sideEffectCleanup) {
4482
4585
  sideEffectCleanup(reason);
@@ -4726,7 +4829,7 @@ const createActionProxyFromSignal = (
4726
4829
  );
4727
4830
  },
4728
4831
  replaceParams: null, // Will be set below
4729
- toString: () => actionProxy.callSource,
4832
+ toString: () => callSourceSignal.peek(), // peek: see the action's toString
4730
4833
  meta: action.meta,
4731
4834
 
4732
4835
  paramsSignal: proxyParamsSignal,
@@ -7268,6 +7371,33 @@ const openCallout = (message, {
7268
7371
  preventScroll: true
7269
7372
  });
7270
7373
  }
7374
+ close_on_escape_from_anywhere: {
7375
+ // Escape closes a callout by reaching its anchor (above), which works while
7376
+ // the anchor holds the keyboard. Some do not: a callout opened with
7377
+ // `skipFocus` leaves the focus where it was, and a press on a blocked list
7378
+ // row leaves it nowhere at all — the row refuses the press, focus included.
7379
+ // The callout is then unreachable by keyboard, and "click somewhere else to
7380
+ // make it go" is not an answer for someone not using a pointer.
7381
+ //
7382
+ // Only for the ones that cannot be heard otherwise, and only while they are
7383
+ // open: a callout the anchor can hear about is left to the anchor, so
7384
+ // Escape goes on meaning what it means where the user is.
7385
+ if (anchorElement && anchorElement.contains(document.activeElement)) {
7386
+ break close_on_escape_from_anywhere;
7387
+ }
7388
+ const onDocumentKeydown = e => {
7389
+ if (e.key !== "Escape") {
7390
+ return;
7391
+ }
7392
+ requestClose(e, "escape_key");
7393
+ e.stopPropagation();
7394
+ e.preventDefault();
7395
+ };
7396
+ document.addEventListener("keydown", onDocumentKeydown, true);
7397
+ addTeardown(() => {
7398
+ document.removeEventListener("keydown", onDocumentKeydown, true);
7399
+ });
7400
+ }
7271
7401
  {
7272
7402
  const handleCustomCloseEvent = e => {
7273
7403
  onRequestClose(e);
@@ -8240,7 +8370,16 @@ const createCalloutManager = (
8240
8370
  // Resolved as the token is added, and kept with it: a token shown later
8241
8371
  // (when the one covering it goes) must be drawn where it was pointing at
8242
8372
  // the time, and the control itself is where a token pointing nowhere goes.
8243
- anchorElement: anchorElement || controller.ref.current,
8373
+ //
8374
+ // Unless the control has somewhere better to send it, which is why it is
8375
+ // given the event: a control drawn by something else — a list row whose
8376
+ // selection is a visually hidden checkbox — answers for a whole box, and
8377
+ // the sentence belongs where the press landed inside it rather than in
8378
+ // its middle (see getCalloutAnchorElement in list_selectable.jsx).
8379
+ anchorElement:
8380
+ anchorElement ||
8381
+ controller.getCalloutAnchorElement?.(event) ||
8382
+ controller.ref.current,
8244
8383
  };
8245
8384
  tokens.set(token, tokenData);
8246
8385
  showToken(tokenData, event);
@@ -10102,6 +10241,15 @@ const readOnlyMessage = (field) => {
10102
10241
  if (reason === "network-policy") {
10103
10242
  return getNetworkPolicyReadOnlyMessage();
10104
10243
  }
10244
+ // As many runs as the surface allows are already out (see parallel_guard.js):
10245
+ // what stops the press is the queue, and it says how long it is.
10246
+ if (reason === "parallel-guard") {
10247
+ const { max } = field.parallelGuard;
10248
+ return naviI18n("constraint.readonly.parallel_guard", {
10249
+ max,
10250
+ s: max > 1 ? "s" : "",
10251
+ });
10252
+ }
10105
10253
  // A send button held back by the form above it, which holds nothing new (see
10106
10254
  // Button's own `readOnlyWhileFormUnchanged`) — what stops the press is not the
10107
10255
  // button, it is the form still waiting for a change, so that is what it says.
@@ -33397,6 +33545,70 @@ document.body.addEventListener(
33397
33545
  { capture: true },
33398
33546
  );
33399
33547
 
33548
+ /**
33549
+ * How many runs a surface lets happen at once.
33550
+ *
33551
+ * A list whose rows each carry their own action is a place to act, row by row,
33552
+ * and nothing about it stops someone from starting a run on every row before
33553
+ * any of them comes back: a dozen requests in flight, a dozen rows waiting, and
33554
+ * a server asked to do a dozen things at once because a list happened to be
33555
+ * long. The guard is what makes the surface say "not yet" instead — the rows
33556
+ * that are not running go read-only and say what they are waiting for, and the
33557
+ * next press is possible again the moment one of the runs comes back.
33558
+ *
33559
+ * It counts runs, not values — which is what separates it from
33560
+ * `maxLengthGuard`, the one that says how many things a selection may HOLD.
33561
+ * Both refuse a gesture; they refuse it for unrelated reasons and a surface can
33562
+ * carry either, or both.
33563
+ */
33564
+ const ParallelGuardContext = createContext(null);
33565
+
33566
+ /**
33567
+ * @param {number} max - how many runs may be in flight at once. `Infinity`
33568
+ * lifts the guard without taking it out of the tree.
33569
+ */
33570
+ const useParallelGuard = (max) => {
33571
+ const guardRef = useRef(null);
33572
+ if (!guardRef.current) {
33573
+ // A Set rather than a counter: a control asks about ITSELF (its own run is
33574
+ // never what blocks it), and a start that arrives twice for one run must
33575
+ // not be counted twice.
33576
+ const runningSet = new Set();
33577
+ const runningCountSignal = signal(0);
33578
+ const guard = {
33579
+ max,
33580
+ runningCountSignal,
33581
+ claim: (controller) => {
33582
+ if (runningSet.has(controller)) {
33583
+ return;
33584
+ }
33585
+ runningSet.add(controller);
33586
+ runningCountSignal.value = runningSet.size;
33587
+ },
33588
+ release: (controller) => {
33589
+ if (!runningSet.delete(controller)) {
33590
+ return;
33591
+ }
33592
+ runningCountSignal.value = runningSet.size;
33593
+ },
33594
+ blocks: (controller) => {
33595
+ if (guard.max === Infinity) {
33596
+ return false;
33597
+ }
33598
+ if (runningSet.has(controller)) {
33599
+ return false;
33600
+ }
33601
+ // Read through the signal so a control held back re-renders — and
33602
+ // becomes pressable again — the moment one of the runs comes back.
33603
+ return runningCountSignal.value >= guard.max;
33604
+ },
33605
+ };
33606
+ guardRef.current = guard;
33607
+ }
33608
+ guardRef.current.max = max;
33609
+ return guardRef.current;
33610
+ };
33611
+
33400
33612
  /**
33401
33613
  * How a control tells the labels pointing at it what it is (disabled, readOnly,
33402
33614
  * required) and when it goes away.
@@ -34312,6 +34524,9 @@ const useUIStateController = (
34312
34524
  resetUIState: (e) => {
34313
34525
  controller.setUIState(controller.state, e);
34314
34526
  },
34527
+ // Read by the callout manager when it has nowhere else to point.
34528
+ getCalloutAnchorElement: (event) =>
34529
+ resolveCalloutAnchorElement(controller.ref.current, event),
34315
34530
  // What the control shows becomes what is acknowledged: the outside said
34316
34531
  // yes to it, so it is where a rollback goes back to. Left alone for a
34317
34532
  // control whose value is GIVEN — the prop is already its truth — and
@@ -34535,6 +34750,44 @@ const firstDefinedChildUIState = (children) => {
34535
34750
  return undefined;
34536
34751
  };
34537
34752
 
34753
+ // Where a control that cannot point at itself puts what it has to say.
34754
+ //
34755
+ // A control drawn by something ELSE answers for a box bigger than itself: a
34756
+ // list row's selection is a visually hidden checkbox inside a whole pressable
34757
+ // row, so a callout anchored on the control lands in the middle of that row,
34758
+ // pointing at nothing. What the press landed on is what the sentence is about
34759
+ // — another control in the same box takes it (the button that invites, the
34760
+ // picker that opens), and the box itself takes what was aimed at the box.
34761
+ //
34762
+ // Answering null leaves the callout where it would have gone, which is also
34763
+ // what a box asks for once and for all with `data-callout-anchor="item"`.
34764
+ const resolveCalloutAnchorElement = (element, event) => {
34765
+ if (!element || !element.closest("[navi-visually-hidden]")) {
34766
+ return null;
34767
+ }
34768
+ const target = event?.target;
34769
+ if (!target || !target.closest) {
34770
+ return null;
34771
+ }
34772
+ const controlRoot = findControlRoot(element);
34773
+ if (
34774
+ !controlRoot ||
34775
+ controlRoot.getAttribute("data-callout-anchor") === "item"
34776
+ ) {
34777
+ return null;
34778
+ }
34779
+ const controlHost = target.closest("[navi-control-host]");
34780
+ if (
34781
+ !controlHost ||
34782
+ controlHost === element ||
34783
+ !controlRoot.contains(controlHost) ||
34784
+ controlHost.closest("[navi-visually-hidden]")
34785
+ ) {
34786
+ return null;
34787
+ }
34788
+ return controlHost;
34789
+ };
34790
+
34538
34791
  // A run that came back with a yes settles what the control is worth — but only
34539
34792
  // for the control the run BELONGS to. Anything with a `command` still fires its
34540
34793
  // own navi_action_* events (it is given a placeholder action so they exist),
@@ -37370,6 +37623,7 @@ const useInteractiveProps = (props, {
37370
37623
  const controlRequired = useContext(RequiredContext);
37371
37624
  const controlLoadingFromAbove = useContext(LoadingContext$1);
37372
37625
  const parentActionRequester = useContext(ActionRequesterContext);
37626
+ const parallelGuard = useContext(ParallelGuardContext);
37373
37627
  const parentAction = useContext(ActionContext);
37374
37628
  const actionStatus = useActionStatus(boundAction);
37375
37629
  const networkPolicyReason = useNetworkPolicyReason();
@@ -37407,7 +37661,12 @@ const useInteractiveProps = (props, {
37407
37661
  // why (see network_policy.js): a press that looks accepted and fails after
37408
37662
  // is what the policy exists to avoid.
37409
37663
  const heldByNetworkPolicy = networkPolicyReason !== null && (isWriteAction(boundAction) || Boolean(zoneStateApplies && parentAction && isWriteAction(parentAction)));
37410
- const readOnlyBase = readOnly || controlReadOnly || loadingBase || readOnlyFromParentMaxLengthGuard || controlInfo.readOnlyUncontrolled || heldByNetworkPolicy;
37664
+ // Held back because the surface above already has as many runs in flight as
37665
+ // it allows (see parallel_guard.js). Only a control that would START one:
37666
+ // anything without an action of its own runs nothing and has nothing to
37667
+ // wait its turn for.
37668
+ const heldByParallelGuard = Boolean(zoneStateApplies && parallelGuard && props.action && parallelGuard.blocks(uiStateController));
37669
+ const readOnlyBase = readOnly || controlReadOnly || loadingBase || readOnlyFromParentMaxLengthGuard || heldByParallelGuard || controlInfo.readOnlyUncontrolled || heldByNetworkPolicy;
37411
37670
  // An optimistic control trusts its action to succeed: the state the user
37412
37671
  // just set stays visible and interactive while the action runs — no
37413
37672
  // loading, no readonly. On failure resetOnError rolls the state back and
@@ -37452,6 +37711,13 @@ const useInteractiveProps = (props, {
37452
37711
  } else {
37453
37712
  controlHostProps["aria-readonly"] = readOnlyResolved ? "true" : "false";
37454
37713
  }
37714
+ // Read by READONLY_CONSTRAINT to say how many runs are already out, and by
37715
+ // the action handlers below, which are where a run joins and leaves the
37716
+ // count (a labelled block away, hence through the controller).
37717
+ uiStateController.parallelGuard = parallelGuard;
37718
+ if (heldByParallelGuard) {
37719
+ controlHostProps["data-readonly-reason"] = "parallel-guard";
37720
+ }
37455
37721
  if (heldByNetworkPolicy) {
37456
37722
  // Read by READONLY_CONSTRAINT; wins over any other reason the control
37457
37723
  // carries — nothing leaves, whatever else was holding it.
@@ -37695,6 +37961,7 @@ const useInteractiveProps = (props, {
37695
37961
  // carried by every navi_action_* event of that run).
37696
37962
  uiStateController.pendingActionEvent = e.detail.event;
37697
37963
  uiStateController.actionInFlight = true;
37964
+ uiStateController.parallelGuard?.claim(uiStateController);
37698
37965
  // The very instance this run uses, resolved now — while the UI state
37699
37966
  // still holds the value the run was made for. detail.action may be a
37700
37967
  // proxy following that state, and by the time anyone wants to abort
@@ -37709,6 +37976,7 @@ const useInteractiveProps = (props, {
37709
37976
  e.detail.addSideEffect(outcome => {
37710
37977
  uiStateController.actionInFlight = false;
37711
37978
  uiStateController.runningAction = null;
37979
+ uiStateController.parallelGuard?.release(uiStateController);
37712
37980
  const queuedEvent = uiStateController.queuedActionAllowedEvent;
37713
37981
  if (!queuedEvent) {
37714
37982
  return;
@@ -56983,12 +57251,22 @@ const translateOf = (axis, distance) =>
56983
57251
 
56984
57252
  const PopupClose = ({
56985
57253
  label,
57254
+ iconSize,
56986
57255
  ...rest
56987
57256
  }) => {
56988
57257
  return jsx(Button, {
56989
57258
  command: "--navi-close",
56990
57259
  icon: true,
56991
57260
  variant: "discrete"
57261
+ // Dismissing writes nothing to whatever control the popup belongs to, so
57262
+ // that control's read-only and disabled are not about this cross: a
57263
+ // read-only picker still opens to be read (see readonly_constraint.js),
57264
+ // and what is opened has to be closable. Whether closing is allowed at
57265
+ // this instant is the popup's own question, answered by the popup — a
57266
+ // dialog holds the close while an action inside it runs (see
57267
+ // findBusyElementInside in dialog.jsx).
57268
+ ,
57269
+ whenSelfInteractionsBlocked: "ignore"
56992
57270
  // The cross is drawn at the control size, which is a few millimetres
56993
57271
  // wide; the padding is what makes it a target a thumb can hit.
56994
57272
  ,
@@ -56997,6 +57275,7 @@ const PopupClose = ({
56997
57275
  "aria-label": label === undefined ? naviI18n("button.close") : label,
56998
57276
  ...rest,
56999
57277
  children: jsx(Icon, {
57278
+ size: iconSize,
57000
57279
  children: jsx(CloseSvg, {})
57001
57280
  })
57002
57281
  });
@@ -63873,6 +64152,11 @@ const ListUI = props => {
63873
64152
  })
63874
64153
  });
63875
64154
  };
64155
+ // How many of a list's own rows may be acting at once, when the rows are what
64156
+ // carry the action. Four rather than none: a list is a place where the same
64157
+ // gesture is available many times over, and nothing else stops a long one from
64158
+ // putting a request out for every row it draws.
64159
+ const PARALLEL_GUARD_DEFAULT = 4;
63876
64160
  const ListFirstResolver = props => {
63877
64161
  const Next = useNextResolver();
63878
64162
  const refDefault = useRef(null);
@@ -63884,8 +64168,13 @@ const ListFirstResolver = props => {
63884
64168
  virtualRef.current = createListVirtual();
63885
64169
  }
63886
64170
  props.virtual = virtualRef.current;
63887
- return jsx(Next, {
63888
- ...props
64171
+ const parallelGuard = useParallelGuard(props.parallelGuard ?? PARALLEL_GUARD_DEFAULT);
64172
+ return jsx(ParallelGuardContext.Provider, {
64173
+ value: parallelGuard,
64174
+ children: jsx(Next, {
64175
+ ...props,
64176
+ parallelGuard: undefined
64177
+ })
63889
64178
  });
63890
64179
  };
63891
64180
 
@@ -63899,6 +64188,7 @@ const ListFirstResolver = props => {
63899
64188
  * deselectable?: boolean,
63900
64189
  * maxLength?: number,
63901
64190
  * maxLengthGuard?: number,
64191
+ * parallelGuard?: number,
63902
64192
  * standalone?: boolean,
63903
64193
  * action?: (value: any) => void,
63904
64194
  * uiAction?: (value: any) => void,
@@ -64075,6 +64365,13 @@ const ListFirstResolver = props => {
64075
64365
  * taking — and `uiAction` is not called. The selected ones stay takeable
64076
64366
  * back, so a selection that arrived too long can always be brought back
64077
64367
  * under the limit. Implies `maxLength` for validity.
64368
+ * @param {number} [props.parallelGuard=4]
64369
+ * How many runs the rows may have in flight at once, for a list whose rows
64370
+ * carry their own `action` (a button per row). While that many are out, every
64371
+ * row that is not running goes read-only and says how many it is waiting on;
64372
+ * the next press is possible again as soon as one comes back. `Infinity`
64373
+ * lifts it. Counts runs, not values — `maxLengthGuard` above is the one that
64374
+ * says how many things the selection may hold.
64078
64375
  * @param {boolean} [props.standalone]
64079
64376
  * This list answers for itself: it does not register with the control group
64080
64377
  * or picker around it, so its selection stays out of that value and nothing
@@ -66067,8 +66364,25 @@ const ListItemReal = props => {
66067
66364
  if (event.button !== 0 || isOverlaidOnRow(event)) {
66068
66365
  return;
66069
66366
  }
66070
- blockInteraction(event);
66071
66367
  pressStartedHereRef.current = true;
66368
+ // A row that IS a control has one already able to answer this, in the same
66369
+ // words it uses for the keyboard, and it is the one the callout would be
66370
+ // anchored on either way — two answers would be two callouts on one anchor,
66371
+ // toggling each other off (see `reopen` in openCallout).
66372
+ //
66373
+ // Asked before the press is blocked, not after: a gate handed an event
66374
+ // already prevented reads it as "someone else took this one" and steps
66375
+ // back without a word (see onRequestInteraction).
66376
+ const controlHost = isControlRoot(event.currentTarget) ? findControlHost(event.currentTarget) : null;
66377
+ if (controlHost) {
66378
+ dispatchRequestInteraction(controlHost, {
66379
+ event,
66380
+ name: "press on a blocked row"
66381
+ });
66382
+ blockInteraction(event);
66383
+ return;
66384
+ }
66385
+ blockInteraction(event);
66072
66386
  // One at a time, and not the one that just dismissed it (see the refs).
66073
66387
  if (calloutRef.current && calloutRef.current.opened) {
66074
66388
  return;