@jsenv/navi 0.29.170 → 0.29.172

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.
@@ -1501,17 +1501,36 @@ naviI18n.addAll({
1501
1501
  fr: "Cette action est en cours...",
1502
1502
  en: "This action is in progress...",
1503
1503
  },
1504
+ // What a ROW is waiting on: the row as a thing the list holds, joining it,
1505
+ // leaving it or being saved. Said "à la liste" / "to the list" on purpose —
1506
+ // in a selectable list, "en cours d'ajout" alone reads as "being added to
1507
+ // the selection", which is the sentence below and a different event.
1504
1508
  "constraint.busy.item": {
1505
1509
  fr: "Cet élément est en cours de synchronisation.",
1506
1510
  en: "This item is being synchronized.",
1507
1511
  },
1508
1512
  "constraint.busy.item.adding": {
1509
- fr: "Cet élément est en cours d'ajout.",
1510
- en: "This item is being added.",
1513
+ fr: "Cet élément est en cours d'ajout à la liste.",
1514
+ en: "This item is being added to the list.",
1511
1515
  },
1512
1516
  "constraint.busy.item.removing": {
1513
- fr: "Cet élément est en cours de suppression.",
1514
- en: "This item is being removed.",
1517
+ fr: "Cet élément est en cours de retrait de la liste.",
1518
+ en: "This item is being removed from the list.",
1519
+ },
1520
+ "constraint.busy.item.updating": {
1521
+ fr: "Cet élément est en cours de mise à jour.",
1522
+ en: "This item is being updated.",
1523
+ },
1524
+ // What the LIST is waiting on: the choice just made, on its way. The subject
1525
+ // is the selection, never a row — the row this is shown on is the one the
1526
+ // user just pressed, which is not the one being committed.
1527
+ "constraint.busy.selection": {
1528
+ fr: "La sélection est en cours d'enregistrement...",
1529
+ en: "This selection is being saved...",
1530
+ },
1531
+ "constraint.busy.choice": {
1532
+ fr: "Le choix est en cours d'enregistrement...",
1533
+ en: "This choice is being saved...",
1515
1534
  },
1516
1535
  "constraint.busy.default": {
1517
1536
  fr: "Cet élément est occupé.",
@@ -2945,6 +2964,13 @@ const debounceSignal = (
2945
2964
  ? compareTwoJsValues(value, debouncedValue)
2946
2965
  : value === debouncedValue
2947
2966
  ) {
2967
+ // The signal came back to where the debounced one already is: whatever it
2968
+ // passed through on the way is not on its way any more. Leaving the timer
2969
+ // armed would publish that in-between value once the delay elapses, and
2970
+ // settlingSignal would then say a newer value is coming, forever.
2971
+ clearTimeout(timeoutId);
2972
+ timeoutId = undefined;
2973
+ latestValue = debouncedValue;
2948
2974
  return;
2949
2975
  }
2950
2976
  clearTimeout(timeoutId);
@@ -3275,15 +3301,23 @@ const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
3275
3301
  * resolves priorities between the four operation sets (reset > rerun > run >
3276
3302
  * prerun) and performs them.
3277
3303
  *
3278
- * Memory design (the surprising part): nothing here keeps actions alive.
3279
- * Child actions are held through ephemerons (createJsValueWeakMap) so a child
3280
- * and its params are garbage-collected together, running actions live in
3281
- * iterable *weak* sets, and property/signal mirroring uses weakEffect. Two
3304
+ * Memory design (the surprising part): by default nothing here keeps actions
3305
+ * alive. Child actions are held through ephemerons (createJsValueWeakMap) so a
3306
+ * child and its params are garbage-collected together, running actions live in
3307
+ * iterable *weak* sets, and property/signal mirroring uses weakEffect. Three
3282
3308
  * consequences to be aware of:
3283
3309
  * - an action can exist in several places only if everyone shares the same
3284
3310
  * instance (the caches above are what makes lookups return it);
3285
3311
  * - prerun actions may have no other reference yet, so
3286
- * prerunProtectionRegistry pins them for a few minutes.
3312
+ * prerunProtectionRegistry pins them until they answer;
3313
+ * - an answer outlives the screen that asked for it only where the question
3314
+ * declared `keep` (createKeptAnswerRegistry); everything else is asked again.
3315
+ *
3316
+ * What makes that design fragile: reading an action's signals from inside an
3317
+ * effect subscribes that effect to them, and the root action never dies — so
3318
+ * the effect, and everything its closure holds, never dies either. Stringifying
3319
+ * an action for a debug message is the easy way to do it by accident, which is
3320
+ * why toString peeks.
3287
3321
  *
3288
3322
  * A failing run rejects, and writes its error into errorSignal as well. The
3289
3323
  * callers that cannot take a rejection — a run started from a signal effect, a
@@ -3398,6 +3432,58 @@ const prerunProtectionRegistry = (() => {
3398
3432
  };
3399
3433
  })();
3400
3434
 
3435
+ /**
3436
+ * The answers a root action keeps once the screen that asked for them is gone.
3437
+ *
3438
+ * An action holds its answer for exactly as long as something references it, so
3439
+ * without this the lifetime of an answer is whatever the garbage collector
3440
+ * decides: coming back to a screen re-asks the same question, or does not,
3441
+ * depending on when a collection happened to run.
3442
+ *
3443
+ * Age is read when someone looks an answer up, never on a timer. A screen
3444
+ * nobody is on must not talk to the network, and an answer expiring under a
3445
+ * screen someone IS on would empty it mid-read.
3446
+ */
3447
+ const createKeptAnswerRegistry = ({ ms, max }) => {
3448
+ // insertion order is recency order: looking an answer up re-inserts it, so
3449
+ // the first key is always the least recently used one
3450
+ const answeredAtMap = new Map();
3451
+
3452
+ return {
3453
+ keep: (action) => {
3454
+ answeredAtMap.delete(action);
3455
+ answeredAtMap.set(action, Date.now());
3456
+ if (max === undefined) {
3457
+ return;
3458
+ }
3459
+ while (answeredAtMap.size > max) {
3460
+ const [leastRecentlyUsed] = answeredAtMap.keys();
3461
+ answeredAtMap.delete(leastRecentlyUsed);
3462
+ }
3463
+ },
3464
+
3465
+ drop: (action) => {
3466
+ answeredAtMap.delete(action);
3467
+ },
3468
+
3469
+ touch: (action) => {
3470
+ const answeredAt = answeredAtMap.get(action);
3471
+ if (answeredAt === undefined) {
3472
+ return;
3473
+ }
3474
+ answeredAtMap.delete(action);
3475
+ if (ms !== undefined && Date.now() - answeredAt > ms) {
3476
+ // reset rather than drop the instance: equal params must keep giving
3477
+ // the same action, otherwise whoever still holds this one would be
3478
+ // reading a different instance than whoever asks for it next.
3479
+ action.reset({ reason: "kept answer expired" });
3480
+ return;
3481
+ }
3482
+ answeredAtMap.set(action, answeredAt);
3483
+ },
3484
+ };
3485
+ };
3486
+
3401
3487
  const formatActionSet = (actionSet, prefix = "") => {
3402
3488
  let message = prefix;
3403
3489
  for (const action of actionSet) {
@@ -3797,12 +3883,38 @@ const mergeActionParams = (currentParams, newParams) => {
3797
3883
  };
3798
3884
 
3799
3885
  const actionWeakMap = new WeakMap();
3886
+ /**
3887
+ * Wraps an async callback into an action: a question, its answer, and the
3888
+ * signals saying where that answer is.
3889
+ *
3890
+ * @param {Function} callback
3891
+ * @param {object} [rootOptions]
3892
+ * @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
3893
+ * answer stays good once it has landed. Without it an answer lives exactly as
3894
+ * long as something references it: the screen that asked goes away, and
3895
+ * coming back asks the same question again at a moment nothing in the code
3896
+ * decides.
3897
+ *
3898
+ * `ms` counts from the moment the answer landed; `max` caps how many answers
3899
+ * are held at once, the least recently looked-at going first. Age is read
3900
+ * when someone looks, never on a timer — a screen nobody is on must not talk
3901
+ * to the network — and an expired answer is `reset()`, so the question goes
3902
+ * back out through whoever asks for it next.
3903
+ *
3904
+ * It is declared here rather than on each binding because the answer to "how
3905
+ * long is this still true?" belongs to the question: a court list is good for
3906
+ * the session, a count of people watching grows on its own and is worth a few
3907
+ * minutes, a payment status is worth nothing at all.
3908
+ */
3800
3909
  const createAction = (callback, rootOptions = {}) => {
3801
3910
  const existing = actionWeakMap.get(callback);
3802
3911
  if (existing) {
3803
3912
  return existing;
3804
3913
  }
3805
3914
 
3915
+ const keptAnswerRegistry = rootOptions.keep
3916
+ ? createKeptAnswerRegistry(rootOptions.keep)
3917
+ : null;
3806
3918
  let rootAction;
3807
3919
 
3808
3920
  const createActionCore = (options, { parentAction } = {}) => {
@@ -4005,6 +4117,9 @@ const createAction = (callback, rootOptions = {}) => {
4005
4117
  * During the delay the instance is still the previous one, holding the
4006
4118
  * previous answer — `paramsSettlingSignal` is what says a newer one is
4007
4119
  * coming, and `useAsyncData(action, { loading: true })` reads it.
4120
+ * The instance follows where the signal settles, never a value it only
4121
+ * passed through: a signal back where it started before the delay elapses
4122
+ * asks nothing.
4008
4123
  */
4009
4124
  const bindParams = (newParamsOrSignal, options = {}) => {
4010
4125
  const { debounce, ...optionsWithoutDebounce } = options;
@@ -4031,6 +4146,9 @@ const createAction = (callback, rootOptions = {}) => {
4031
4146
  }
4032
4147
  const existingChildAction = childActionWeakMap.get(newParamsOrSignal);
4033
4148
  if (existingChildAction) {
4149
+ if (keptAnswerRegistry) {
4150
+ keptAnswerRegistry.touch(existingChildAction);
4151
+ }
4034
4152
  return existingChildAction;
4035
4153
  }
4036
4154
  const childAction = _bindParams(
@@ -4169,7 +4287,10 @@ const createAction = (callback, rootOptions = {}) => {
4169
4287
  paramsSignal.value = nextParams;
4170
4288
  return true;
4171
4289
  },
4172
- toString: () => action.callSource,
4290
+ // peek: an action gets stringified into debug messages built inside
4291
+ // effects, and subscribing there would keep those effects — and everything
4292
+ // their closures hold — alive for as long as the action itself.
4293
+ toString: () => actionCallSourceSignal.peek(),
4173
4294
  meta,
4174
4295
  debug: (...args) => {
4175
4296
  if (!meta.debug && !DEBUG$2) {
@@ -4333,6 +4454,9 @@ const createAction = (callback, rootOptions = {}) => {
4333
4454
  onComplete?.(data, action);
4334
4455
  completeSideEffectCleanup = completeSideEffect?.(action);
4335
4456
  });
4457
+ if (keptAnswerRegistry) {
4458
+ keptAnswerRegistry.keep(action);
4459
+ }
4336
4460
  if (DEBUG$2) {
4337
4461
  console.log(`"${action}": completed`);
4338
4462
  }
@@ -4448,6 +4572,9 @@ const createAction = (callback, rootOptions = {}) => {
4448
4572
  }
4449
4573
 
4450
4574
  prerunProtectionRegistry.unprotect(action);
4575
+ if (keptAnswerRegistry) {
4576
+ keptAnswerRegistry.drop(action);
4577
+ }
4451
4578
 
4452
4579
  if (sideEffectCleanup) {
4453
4580
  sideEffectCleanup(reason);
@@ -4697,7 +4824,7 @@ const createActionProxyFromSignal = (
4697
4824
  );
4698
4825
  },
4699
4826
  replaceParams: null, // Will be set below
4700
- toString: () => actionProxy.callSource,
4827
+ toString: () => callSourceSignal.peek(), // peek: see the action's toString
4701
4828
  meta: action.meta,
4702
4829
 
4703
4830
  paramsSignal: proxyParamsSignal,
@@ -4900,7 +5027,9 @@ const runUnwatched = (startRun) => {
4900
5027
  * keystroke: set `debounce: 500` and the request is sent only after the user stops interacting
4901
5028
  * with the filters for 500 ms. During that delay the action still holds the previous answer;
4902
5029
  * `action.paramsSettlingSignal` is what says a newer one is coming, and
4903
- * `useAsyncData(action, { loading: true })` reads it.
5030
+ * `useAsyncData(action, { loading: true })` reads it. The run follows where
5031
+ * the params settle, never a value they only passed through: params back
5032
+ * where they started before the delay elapses run nothing.
4904
5033
  *
4905
5034
  * Example — auto-refresh a result list while the user tweaks filters:
4906
5035
  * ```js
@@ -5594,6 +5723,7 @@ const CONSTRAINT_NAME_TO_PROP = {
5594
5723
  min_special_char: "minSpecialCharMessage",
5595
5724
  one_of: "oneOfMessage",
5596
5725
  readonly: "readOnlyMessage",
5726
+ busy: "busyMessage",
5597
5727
  available: "availableMessage",
5598
5728
  };
5599
5729
 
@@ -6209,18 +6339,32 @@ const findControlProxies = (el) => {
6209
6339
  };
6210
6340
 
6211
6341
  /**
6212
- * Given a real control element, returns the proxy that visually represents it.
6342
+ * Given a real control element, returns the proxy a gesture on the control
6343
+ * lands on — the one thing said about the control is drawn on.
6213
6344
  *
6214
6345
  * Use when you need a single visible stand-in for the real control — anchoring
6215
6346
  * a callout, for instance. Anything notifying proxies of a state change wants
6216
6347
  * `findControlProxies` instead, so a control represented by several of them
6217
6348
  * updates all of them.
6218
6349
  *
6219
- * Returns `null` when no proxy exists for `el`.
6350
+ * A proxy the pointer cannot reach is drawn for the control without standing
6351
+ * for it: the surface around it takes the presses for everything it holds, so
6352
+ * that surface is where a refusal about the control belongs — not a box inside
6353
+ * it the finger never has to find. A selectable list row whose whole area is
6354
+ * the click target is exactly that arrangement (see `selectableArea`), and it
6355
+ * is read from the arrangement itself rather than from a flag repeating it:
6356
+ * where a gesture can land IS pointer-events.
6357
+ *
6358
+ * Returns `null` when no proxy stands for `el`.
6220
6359
  */
6221
6360
  const findControlProxy = (el) => {
6222
- const [firstProxy = null] = findControlProxies(el);
6223
- return firstProxy;
6361
+ for (const proxyElement of findControlProxies(el)) {
6362
+ if (getComputedStyle(proxyElement).pointerEvents === "none") {
6363
+ continue;
6364
+ }
6365
+ return proxyElement;
6366
+ }
6367
+ return null;
6224
6368
  };
6225
6369
 
6226
6370
  /**
@@ -7222,6 +7366,33 @@ const openCallout = (message, {
7222
7366
  preventScroll: true
7223
7367
  });
7224
7368
  }
7369
+ close_on_escape_from_anywhere: {
7370
+ // Escape closes a callout by reaching its anchor (above), which works while
7371
+ // the anchor holds the keyboard. Some do not: a callout opened with
7372
+ // `skipFocus` leaves the focus where it was, and a press on a blocked list
7373
+ // row leaves it nowhere at all — the row refuses the press, focus included.
7374
+ // The callout is then unreachable by keyboard, and "click somewhere else to
7375
+ // make it go" is not an answer for someone not using a pointer.
7376
+ //
7377
+ // Only for the ones that cannot be heard otherwise, and only while they are
7378
+ // open: a callout the anchor can hear about is left to the anchor, so
7379
+ // Escape goes on meaning what it means where the user is.
7380
+ if (anchorElement && anchorElement.contains(document.activeElement)) {
7381
+ break close_on_escape_from_anywhere;
7382
+ }
7383
+ const onDocumentKeydown = e => {
7384
+ if (e.key !== "Escape") {
7385
+ return;
7386
+ }
7387
+ requestClose(e, "escape_key");
7388
+ e.stopPropagation();
7389
+ e.preventDefault();
7390
+ };
7391
+ document.addEventListener("keydown", onDocumentKeydown, true);
7392
+ addTeardown(() => {
7393
+ document.removeEventListener("keydown", onDocumentKeydown, true);
7394
+ });
7395
+ }
7225
7396
  {
7226
7397
  const handleCustomCloseEvent = e => {
7227
7398
  onRequestClose(e);
@@ -8008,6 +8179,11 @@ const generateSvgWithoutArrow = (width, height) => {
8008
8179
  */
8009
8180
 
8010
8181
 
8182
+ // The close reason the manager gives itself when the callout has to be drawn on
8183
+ // another element: it tears the callout down and opens it again, and this is
8184
+ // what tells that apart from the callout being dismissed.
8185
+ const MOVING_TO_ANOTHER_ANCHOR = "moving_to_another_anchor";
8186
+
8011
8187
  /**
8012
8188
  * Creates an opaque token used as a key for callout open reasons.
8013
8189
  * Each caller (validation, interaction, …) owns one token.
@@ -8031,10 +8207,106 @@ const createCalloutManager = (
8031
8207
  const [notifyCalloutOpen, onCalloutOpen] = createPubSub();
8032
8208
 
8033
8209
  let callout = null;
8210
+ // Where the open callout is drawn. What a token points at is compared against
8211
+ // it to know whether showing that token is a new message on the same element
8212
+ // or the same conversation moving to another one.
8213
+ let calloutAnchorElement = null;
8034
8214
  // Tracks open tokens → their constraint info.
8035
8215
  // The callout closes automatically when the last token is removed.
8036
8216
  const tokens = new Map();
8037
8217
 
8218
+ const openCalloutForToken = (tokenData, event) => {
8219
+ const { anchorElement } = tokenData;
8220
+ calloutAnchorElement = anchorElement;
8221
+ const removeCloseOnCleanup = addTeardown?.(() => {
8222
+ requestCloseCallout(new CustomEvent("cleanup"), "cleanup");
8223
+ });
8224
+ // `openResults` is referenced in onClose which runs later — forward ref is intentional.
8225
+ let openResults = [];
8226
+ callout = openCallout(tokenData.message, {
8227
+ status: tokenData.status,
8228
+ testId: tokenData.testId,
8229
+ icon: tokenData.icon,
8230
+ closeButton: tokenData.closeButton,
8231
+ closeOnClickOutside: tokenData.status !== "error",
8232
+ anchorElement,
8233
+ openingEvent: event,
8234
+ skipFocus: tokenData.skipFocus,
8235
+ debug: debugPopup,
8236
+ onClose: ({
8237
+ event: closeEvent,
8238
+ reason,
8239
+ shouldTransferFocusFromCallout,
8240
+ }) => {
8241
+ removeCloseOnCleanup?.();
8242
+ for (const result of openResults) {
8243
+ if (typeof result === "function") {
8244
+ result();
8245
+ }
8246
+ }
8247
+ callout = null;
8248
+ calloutAnchorElement = null;
8249
+ if (reason === MOVING_TO_ANOTHER_ANCHOR) {
8250
+ // The control still has the same things to say, on another element:
8251
+ // the tokens stand and nothing was dismissed, so neither the token
8252
+ // callbacks nor the focus hand-back below apply.
8253
+ return;
8254
+ }
8255
+ // User dismissed the callout — notify all active tokens then clear.
8256
+ // Told what closed it: a token whose content is a popup of its own (a
8257
+ // picker in callout mode) closes that popup on the same event.
8258
+ for (const [, otherTokenData] of tokens) {
8259
+ otherTokenData.onClose?.({ event: closeEvent, reason });
8260
+ }
8261
+ tokens.clear();
8262
+ const element = controller.ref.current;
8263
+ if (
8264
+ shouldTransferFocusFromCallout &&
8265
+ element &&
8266
+ !element.closest('[aria-hidden="true"]')
8267
+ ) {
8268
+ const focusTarget =
8269
+ findFocusDelegateTarget(anchorElement) || anchorElement;
8270
+ debugFocus(
8271
+ closeEvent,
8272
+ `callout is closing with focus, give focus back to the control ${getElementSignature(focusTarget)}.focus()`,
8273
+ );
8274
+ focusTarget.focus();
8275
+ }
8276
+ },
8277
+ });
8278
+ openResults = notifyCalloutOpen(event);
8279
+ };
8280
+
8281
+ // What a token has to say, drawn where that token points. A control has one
8282
+ // callout, so a token takes it over from whoever held it — and a token about
8283
+ // another element (an action refused about the row that was pressed, on a
8284
+ // list that owns the action) moves it there, because a sentence about a row
8285
+ // drawn on the list is a sentence pointing at the wrong thing.
8286
+ const showToken = (tokenData, event) => {
8287
+ if (tokenData.anchorElement && !tokenData.anchorElement.isConnected) {
8288
+ // What the token pointed at has left the page — a row removed while its
8289
+ // refusal was still waiting behind another one. There is nothing left to
8290
+ // draw on there, and the control holding the token is still here, so it
8291
+ // takes it (same rule as an action error whose requester is gone, see
8292
+ // use_execute_action.js).
8293
+ tokenData.anchorElement = controller.ref.current;
8294
+ }
8295
+ if (callout) {
8296
+ if (tokenData.anchorElement === calloutAnchorElement) {
8297
+ callout.update(tokenData.message, {
8298
+ status: tokenData.status,
8299
+ testId: tokenData.testId,
8300
+ icon: tokenData.icon,
8301
+ closeButton: tokenData.closeButton,
8302
+ });
8303
+ return;
8304
+ }
8305
+ callout.requestClose(event, MOVING_TO_ANOTHER_ANCHOR);
8306
+ }
8307
+ openCalloutForToken(tokenData, event);
8308
+ };
8309
+
8038
8310
  // Remove a token. Closes the callout only when no tokens remain.
8039
8311
  // If other tokens are still active, updates the callout to show the first remaining one.
8040
8312
  const removeOpenToken = (token, event) => {
@@ -8045,12 +8317,7 @@ const createCalloutManager = (
8045
8317
  if (tokens.size > 0) {
8046
8318
  if (callout) {
8047
8319
  const [, remainingTokenData] = tokens.entries().next().value;
8048
- callout.update(remainingTokenData.message, {
8049
- status: remainingTokenData.status,
8050
- testId: remainingTokenData.testId,
8051
- icon: remainingTokenData.icon,
8052
- closeButton: remainingTokenData.closeButton,
8053
- });
8320
+ showToken(remainingTokenData, event);
8054
8321
  }
8055
8322
  return false;
8056
8323
  }
@@ -8087,70 +8354,21 @@ const createCalloutManager = (
8087
8354
  removeOpenToken(token, event);
8088
8355
  return;
8089
8356
  }
8090
- const calloutOptions = {
8357
+ const tokenData = {
8358
+ message,
8091
8359
  status,
8092
8360
  testId,
8093
8361
  icon,
8094
8362
  closeButton,
8095
- closeOnClickOutside: status !== "error",
8096
- };
8097
-
8098
- tokens.set(token, { message, status, testId, icon, closeButton, onClose });
8099
- if (callout) {
8100
- callout.update(message, calloutOptions);
8101
- return;
8102
- }
8103
- const resolvedAnchorElement = anchorElement || controller.ref.current;
8104
- const removeCloseOnCleanup = addTeardown?.(() => {
8105
- requestCloseCallout(new CustomEvent("cleanup"), "cleanup");
8106
- });
8107
- // `openResults` is referenced in onClose which runs later — forward ref is intentional.
8108
- let openResults = [];
8109
- callout = openCallout(message, {
8110
- ...calloutOptions,
8111
- anchorElement: resolvedAnchorElement,
8112
- openingEvent: event,
8113
8363
  skipFocus,
8114
- debug: debugPopup,
8115
- onClose: ({
8116
- event: closeEvent,
8117
- reason,
8118
- shouldTransferFocusFromCallout,
8119
- }) => {
8120
- removeCloseOnCleanup?.();
8121
- for (const result of openResults) {
8122
- if (typeof result === "function") {
8123
- result();
8124
- }
8125
- }
8126
- callout = null;
8127
- // User dismissed the callout — notify all active tokens then clear.
8128
- // Told what closed it: a token whose content is a popup of its own (a
8129
- // picker in callout mode) closes that popup on the same event.
8130
- for (const [, tokenData] of tokens) {
8131
- tokenData.onClose?.({ event: closeEvent, reason });
8132
- }
8133
- tokens.clear();
8134
- const element = controller.ref.current;
8135
- if (
8136
- shouldTransferFocusFromCallout &&
8137
- element &&
8138
- !element.closest('[aria-hidden="true"]')
8139
- ) {
8140
- const focusTarget =
8141
- findFocusDelegateTarget(resolvedAnchorElement) ||
8142
- resolvedAnchorElement;
8143
- debugFocus(
8144
- closeEvent,
8145
- `callout is closing with focus, give focus back to the control ${getElementSignature(focusTarget)}.focus()`,
8146
- );
8147
- focusTarget.focus();
8148
- }
8149
- },
8150
- });
8151
- // `onOpen` can be a createPubSub publisher — its return value is an array of cleanup fns.
8152
- // Or just a plain callback — wrap the single return value in an array.
8153
- openResults = notifyCalloutOpen(event);
8364
+ onClose,
8365
+ // Resolved as the token is added, and kept with it: a token shown later
8366
+ // (when the one covering it goes) must be drawn where it was pointing at
8367
+ // the time, and the control itself is where a token pointing nowhere goes.
8368
+ anchorElement: anchorElement || controller.ref.current,
8369
+ };
8370
+ tokens.set(token, tokenData);
8371
+ showToken(tokenData, event);
8154
8372
  };
8155
8373
 
8156
8374
  const calloutManager = {
@@ -8176,12 +8394,21 @@ const BUSY_CONSTRAINT = {
8176
8394
  transient: true,
8177
8395
  // Unlike readonly/disabled, a busy element DOES block its parent from
8178
8396
  // submitting — the element is mid-operation and cannot safely participate.
8179
- check: (field) => {
8397
+ check: (field, { intent } = {}) => {
8180
8398
  const isBusy = isControlBusy(field);
8181
8399
  if (!isBusy) {
8182
8400
  return null;
8183
8401
  }
8184
8402
 
8403
+ // Busy, and what it opens still opens: a picker's answer lives in a shape
8404
+ // only its popup draws, and refusing to open leaves it unreadable for as
8405
+ // long as the wait lasts. Opening reads and nothing more — the same
8406
+ // exemption read-only makes, on the same controls (see
8407
+ // READONLY_CONSTRAINT), so that "read" means one thing to both.
8408
+ if (intent === "read" && field.readOnlyOpens) {
8409
+ return null;
8410
+ }
8411
+
8185
8412
  const isButton = field.controlType === "button";
8186
8413
  const message = isButton
8187
8414
  ? naviI18n("constraint.busy.button")
@@ -8199,9 +8426,9 @@ CONSTRAINT_ATTRIBUTE_SET.add("data-busy");
8199
8426
  // whether it may finally close, a slide whether it may move on).
8200
8427
  //
8201
8428
  // The action's running state is a signal, so it is already right there. A
8202
- // control busy only because the group above is running the action it asked for
8203
- // has no state of its own to read — the group's answer IS its answer, so it
8204
- // asks upward and inherits the same live reading.
8429
+ // control busy only because the group above is waiting has no state of its own
8430
+ // to read — the group's answer IS its answer, so it asks upward and inherits
8431
+ // the same live reading.
8205
8432
  const isControlBusy = (field) => {
8206
8433
  if (field.loadingFromOwnProp) {
8207
8434
  return true;
@@ -8225,7 +8452,7 @@ const isControlBusy = (field) => {
8225
8452
  return true;
8226
8453
  }
8227
8454
  }
8228
- if (field.loadingFromParent) {
8455
+ if (field.loadingFromAbove) {
8229
8456
  const parent = field.parentUIStateController;
8230
8457
  return parent ? isControlBusy(parent) : false;
8231
8458
  }
@@ -10063,6 +10290,9 @@ const createControlInteraction = (
10063
10290
  let failingManagedInteraction = null;
10064
10291
  // The title this rule put on the element, if any (see checkInteractivity).
10065
10292
  let titleWritten = null;
10293
+ // The constraint this control last refused a press with, while that refusal
10294
+ // is on screen — what refreshReport has to take back once it stops holding.
10295
+ let reportedConstraint = null;
10066
10296
 
10067
10297
  const checkInteractivity = ({ event, intent = "write" } = {}) => {
10068
10298
  interactionFailedConstraintInfo = null;
@@ -10153,20 +10383,50 @@ const createControlInteraction = (
10153
10383
 
10154
10384
  const canInteract =
10155
10385
  !interactionFailedConstraintInfo && !failingManagedInteraction;
10156
- // When the control is now interactable, remove the interaction token
10386
+ // When the control can be interacted with, remove the interaction token
10157
10387
  // so the callout closes if no other tokens (e.g. validation) are active.
10158
10388
  if (canInteract) {
10159
10389
  callout.removeOpenToken(INTERACTION_TOKEN, event);
10390
+ reportedConstraint = null;
10160
10391
  }
10161
10392
  return canInteract;
10162
10393
  };
10163
10394
 
10395
+ // What this rule says out loud is a LIVE reading, and nothing else comes back
10396
+ // to correct it: busy stops being true on its own (see BUSY_CONSTRAINT's
10397
+ // `transient`), and read-only inherited from a group goes with the run that
10398
+ // set it. Read again whenever the control's interactivity moves, so a refusal
10399
+ // is taken back once what it says stops being true — a callout left open over
10400
+ // a control it no longer describes also sits on top of it and swallows the
10401
+ // next press.
10402
+ //
10403
+ // Only when something WAS said: reading a control that has refused nothing
10404
+ // would start writing titles nobody asked for.
10405
+ const refreshReport = (event) => {
10406
+ const constraintReported = reportedConstraint;
10407
+ if (!constraintReported && titleWritten === null) {
10408
+ return;
10409
+ }
10410
+ checkInteractivity({ event });
10411
+ if (!reportedConstraint) {
10412
+ return;
10413
+ }
10414
+ if (interactionFailedConstraintInfo?.constraint === constraintReported) {
10415
+ return;
10416
+ }
10417
+ // Held still, but by something else — and the press that was answered is
10418
+ // long over, so it is taken back rather than answered a second time.
10419
+ callout.removeOpenToken(INTERACTION_TOKEN, event);
10420
+ reportedConstraint = null;
10421
+ };
10422
+
10164
10423
  const reportInteractivity = ({ event } = {}) => {
10165
10424
  if (failingManagedInteraction) {
10166
10425
  // Report on the specific child that is blocking, not the parent.
10167
10426
  failingManagedInteraction.reportInteractivity({ event });
10168
10427
  return;
10169
10428
  }
10429
+ reportedConstraint = interactionFailedConstraintInfo.constraint;
10170
10430
  debugInteraction(
10171
10431
  event,
10172
10432
  `reportInteractivity (${interactionFailedConstraintInfo.name})`,
@@ -10189,6 +10449,7 @@ const createControlInteraction = (
10189
10449
  const controlInteraction = {
10190
10450
  checkInteractivity,
10191
10451
  reportInteractivity,
10452
+ refreshReport,
10192
10453
  };
10193
10454
  Object.defineProperty(controlInteraction, "interactionFailedConstraintInfo", {
10194
10455
  get: () => interactionFailedConstraintInfo,
@@ -10237,6 +10498,10 @@ const onRequestInteraction = (
10237
10498
  // through (see READONLY_CONSTRAINT).
10238
10499
  intent = "write",
10239
10500
  bypassInteractivity = false,
10501
+ // navi continuing a gesture on its own rather than a user asking for
10502
+ // something: the decision still applies, the explanation does not (see
10503
+ // below).
10504
+ automatic = false,
10240
10505
  // Who asked, when the control is not answering the gesture on its own —
10241
10506
  // the source of a command (see the self-interactions step-back below).
10242
10507
  requester,
@@ -10297,7 +10562,16 @@ const onRequestInteraction = (
10297
10562
  const reason = failedInfo
10298
10563
  ? `failing interaction constraint "${failedInfo.name}"`
10299
10564
  : "not interactable";
10300
- ci.reportInteractivity({ event });
10565
+ // A refusal is explained to whoever asked, and an automatic follow-up
10566
+ // has nobody waiting for one: a group whose action a part just asked
10567
+ // for is busy running exactly that (see auto_group_action in
10568
+ // control_hooks.jsx), so the follow-up is turned down — which is what
10569
+ // keeps the action from running twice — while the press that started
10570
+ // it went through. Saying "this element is busy" there answers an
10571
+ // accepted press with a refusal.
10572
+ if (!automatic) {
10573
+ ci.reportInteractivity({ event });
10574
+ }
10301
10575
  onPrevented(reason);
10302
10576
  return false;
10303
10577
  }
@@ -10351,6 +10625,11 @@ const dispatchRequestAction = (
10351
10625
  {
10352
10626
  event,
10353
10627
  name = "dispatchRequestAction",
10628
+ // The gate's own options, named here so they reach it: everything left in
10629
+ // actionOptions goes to the action alone, so a gate option arriving under
10630
+ // any other name is dropped without a word.
10631
+ automatic,
10632
+ bypassInteractivity,
10354
10633
  prevented,
10355
10634
  allowed,
10356
10635
  always,
@@ -10360,6 +10639,8 @@ const dispatchRequestAction = (
10360
10639
  return dispatchRequestInteraction(element, {
10361
10640
  event,
10362
10641
  name,
10642
+ automatic,
10643
+ bypassInteractivity,
10363
10644
  // The gate needs it as much as the action does: the requester may be an
10364
10645
  // affordance that claimed the press to ask for this very action, and the
10365
10646
  // control must not read that claim as "this press was not for me" — see
@@ -12965,9 +13246,10 @@ const useExecuteAction = (
12965
13246
  }, [error]);
12966
13247
 
12967
13248
  const addErrorMessage = (error, { requester } = {}) => {
12968
- // The error is stored on the element that owns the action (the form/element itself).
12969
- // The requester (e.g. submit button) is stored as the callout display target
12970
- // so the validation message appears on the button, not the form.
13249
+ // The error is stored on the element that owns the action (the form/element
13250
+ // itself). The requester — the submit button that sent, the row of a list
13251
+ // whose command sent — is stored as the callout display target, so the
13252
+ // message appears on whoever asked rather than on the whole control.
12971
13253
  const element = elementRef.current;
12972
13254
  let target = requester;
12973
13255
  // A requester that is no longer on the page is not a place to show
@@ -31300,6 +31582,12 @@ const anyMatchingRouteSignal = (routes) => {
31300
31582
  * @param {Element} [options.anchor] Where a popup this opens should be placed,
31301
31583
  * when that is not the element asking: a menu opened by a press belongs at the
31302
31584
  * point the press happened, and the row that was pressed is not that point.
31585
+ * @param {Element} [options.requester] Who asked, when that is not the source.
31586
+ * A group triggers its own command from itself — what the command aims at is
31587
+ * read around the group, not around the child — while the gesture was on one
31588
+ * of its children, and what a refusal is about is that child. Same role as
31589
+ * the submit button a form's send names: the action belongs to the control,
31590
+ * the answer is drawn on whoever asked for it.
31303
31591
  */
31304
31592
  const triggerNaviCommand = (element, command, event, options) => {
31305
31593
  const run = resolveNaviCommand(element, command, event, options);
@@ -31330,7 +31618,7 @@ const resolveNaviCommand = (
31330
31618
  element,
31331
31619
  command,
31332
31620
  event,
31333
- { optional, value, anchor } = {},
31621
+ { optional, value, anchor, requester } = {},
31334
31622
  ) => {
31335
31623
  if (!event) {
31336
31624
  throw new Error(
@@ -31359,6 +31647,7 @@ const resolveNaviCommand = (
31359
31647
  // source: the attribute form has a `value` to read (`<Button value={id}>`),
31360
31648
  // a JS decision has none, and both must be able to say the same thing.
31361
31649
  value,
31650
+ requester,
31362
31651
  });
31363
31652
  if (!execute) {
31364
31653
  if (optional) {
@@ -31542,7 +31831,8 @@ const onNaviCommand = (e, { debugCommand = () => {} } = {}) => {
31542
31831
  };
31543
31832
 
31544
31833
  const NAVI_COMMANDS = {};
31545
- // commandHandler(source, event) → { target, implementation } | undefined
31834
+ // commandHandler(source, event, { argument, anchor, value, requester })
31835
+ // → { target, implementation } | undefined
31546
31836
  // - Each handler calls resolveExplicitTarget(source) first, then falls back to
31547
31837
  // its own DOM resolution logic (closest expandable, parent control, etc.).
31548
31838
  // - Returns undefined when no target can be found — this is a normal outcome for
@@ -31780,7 +32070,7 @@ const resolveAfterSend = (target, requester) => {
31780
32070
  return undefined;
31781
32071
  };
31782
32072
 
31783
- registerNaviCommand("--navi-send", (source, event) => {
32073
+ registerNaviCommand("--navi-send", (source, event, { requester }) => {
31784
32074
  const expandable = resolveExpandableAround(source);
31785
32075
  const target =
31786
32076
  resolveExplicitTarget(source) ||
@@ -31823,14 +32113,21 @@ registerNaviCommand("--navi-send", (source, event) => {
31823
32113
  return {
31824
32114
  target,
31825
32115
  implementation: () => {
31826
- let requester = source;
31827
- if (!source.matches(submitSelector)) {
31828
- // When present, use the first submit button as the requester, not the input.
31829
- // This aligns with browser behavior where Enter in a text input triggers
31830
- // the first submit button of the form, not the input itself.
31831
- const firstButtonSubmitting = target.querySelector(submitSelector);
31832
- if (firstButtonSubmitting) {
31833
- requester = firstButtonSubmitting;
32116
+ // Who asked, taken from whoever triggered the command when it said so —
32117
+ // a group sends from itself and names the child the gesture was on (see
32118
+ // triggerNaviCommand's `requester`). Named means named: the stand-in
32119
+ // below is for a source nobody spoke for.
32120
+ let sendRequester = requester;
32121
+ if (!sendRequester) {
32122
+ sendRequester = source;
32123
+ if (!source.matches(submitSelector)) {
32124
+ // When present, use the first submit button as the requester, not the input.
32125
+ // This aligns with browser behavior where Enter in a text input triggers
32126
+ // the first submit button of the form, not the input itself.
32127
+ const firstButtonSubmitting = target.querySelector(submitSelector);
32128
+ if (firstButtonSubmitting) {
32129
+ sendRequester = firstButtonSubmitting;
32130
+ }
31834
32131
  }
31835
32132
  }
31836
32133
  // Nothing is committed when a constraint fails, so nothing is decided
@@ -31844,7 +32141,7 @@ registerNaviCommand("--navi-send", (source, event) => {
31844
32141
  // from the response writes it on the form while it runs
31845
32142
  // (data-after-send), and this is what picks it up.
31846
32143
  const runAfterSend = () => {
31847
- const afterSend = resolveAfterSend(target, requester);
32144
+ const afterSend = resolveAfterSend(target, sendRequester);
31848
32145
  if (!afterSend) {
31849
32146
  return;
31850
32147
  }
@@ -31879,7 +32176,7 @@ registerNaviCommand("--navi-send", (source, event) => {
31879
32176
  initiator.preventDefault();
31880
32177
  }
31881
32178
  },
31882
- requester,
32179
+ requester: sendRequester,
31883
32180
  }),
31884
32181
  );
31885
32182
  if (sent === false || invalid) {
@@ -34140,8 +34437,20 @@ const useUIStateController = (
34140
34437
  resetUIState: (e) => {
34141
34438
  controller.setUIState(controller.state, e);
34142
34439
  },
34440
+ // What the control shows becomes what is acknowledged: the outside said
34441
+ // yes to it, so it is where a rollback goes back to. Left alone for a
34442
+ // control whose value is GIVEN — the prop is already its truth — and
34443
+ // while a newer request waits behind this one, whose own answer is what
34444
+ // will settle what is accepted.
34445
+ acknowledgeUIState: () => {
34446
+ if (controller.hasStateProp || controller.queuedActionAllowedEvent) {
34447
+ return;
34448
+ }
34449
+ controller.state = controller.uiState;
34450
+ },
34143
34451
  onActionEnd: (e) => {
34144
34452
  debugUIState(`"${controlType}" actionEnd called`);
34453
+ acknowledgeOwnAction(controller);
34145
34454
  controller.rules.validation.syncValidity(e);
34146
34455
  },
34147
34456
  onActionError: (e) => {
@@ -34351,6 +34660,18 @@ const firstDefinedChildUIState = (children) => {
34351
34660
  return undefined;
34352
34661
  };
34353
34662
 
34663
+ // A run that came back with a yes settles what the control is worth — but only
34664
+ // for the control the run BELONGS to. Anything with a `command` still fires its
34665
+ // own navi_action_* events (it is given a placeholder action so they exist),
34666
+ // and taking those for an acknowledgement is how a row would remember its own
34667
+ // selection as accepted and hand it back on the group's rollback.
34668
+ const acknowledgeOwnAction = (controller) => {
34669
+ if (!controller.props.action) {
34670
+ return;
34671
+ }
34672
+ controller.acknowledgeUIState();
34673
+ };
34674
+
34354
34675
  // Default aggregate/distribute implementations keyed by controlType or stateType.
34355
34676
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
34356
34677
  // distributeChildUIState. If neither a default nor an explicit impl is found for a
@@ -34818,7 +35139,7 @@ const useUIGroupStateController = (
34818
35139
  { except: actingChild },
34819
35140
  );
34820
35141
  }
34821
- applyState(groupUIState, e);
35142
+ applyState(groupUIState, e, { actingChild });
34822
35143
  } else if (notifyExternal === "silent") {
34823
35144
  controller.syncInternalState(groupUIState);
34824
35145
  s.parentUIStateController?.onChildUIAction(controller, e, {
@@ -34872,7 +35193,11 @@ const useUIGroupStateController = (
34872
35193
  childUIStateController.setUIState(childNewState, e);
34873
35194
  };
34874
35195
 
34875
- const applyState = (newUIState, e, { internalBehavior = false } = {}) => {
35196
+ const applyState = (
35197
+ newUIState,
35198
+ e,
35199
+ { internalBehavior = false, actingChild } = {},
35200
+ ) => {
34876
35201
  const { controller } = s;
34877
35202
  const currentUIState = controller.uiState;
34878
35203
  controller.uiState = newUIState;
@@ -34893,7 +35218,10 @@ const useUIGroupStateController = (
34893
35218
  s.parentUIStateController?.onChildUIAction(controller, e, {
34894
35219
  stateChanged: true,
34895
35220
  });
34896
- controller.onUIAction(e, { skipCommand: internalBehavior });
35221
+ controller.onUIAction(e, {
35222
+ skipCommand: internalBehavior,
35223
+ actingChild,
35224
+ });
34897
35225
  const el = controller.ref.current;
34898
35226
  if (el) {
34899
35227
  dispatchInternalCustomEvent(el, "navi_ui_state_change", {
@@ -35032,7 +35360,7 @@ const useUIGroupStateController = (
35032
35360
  uiStateSignal.value = newUIState;
35033
35361
  publishUIState(newUIState);
35034
35362
  },
35035
- onUIAction: (e, { skipCommand } = {}) => {
35363
+ onUIAction: (e, { skipCommand, actingChild } = {}) => {
35036
35364
  const currentUIState = controller.uiState;
35037
35365
  // The same write applyState/onChange make (see writeBoundSignal), for
35038
35366
  // the case where the state did not move but the user acted all the
@@ -35046,7 +35374,14 @@ const useUIGroupStateController = (
35046
35374
  if (!skipCommand && controller.props.command) {
35047
35375
  const el = controller.ref.current;
35048
35376
  if (el) {
35049
- triggerNaviCommand(el, controller.props.command, e);
35377
+ // The command is the GROUP's — what it aims at is read around the
35378
+ // group, not around the child — but the child is who asked, and a
35379
+ // refusal is drawn on whoever asked (see triggerNaviCommand's
35380
+ // `requester`). Without it a list refusing a name says so at its
35381
+ // own foot, rows away from the name that was touched.
35382
+ triggerNaviCommand(el, controller.props.command, e, {
35383
+ requester: actingChild?.ref.current,
35384
+ });
35050
35385
  }
35051
35386
  }
35052
35387
  },
@@ -35141,7 +35476,7 @@ const useUIGroupStateController = (
35141
35476
  actingChild: childUIStateController,
35142
35477
  });
35143
35478
  } else {
35144
- controller.onUIAction(e);
35479
+ controller.onUIAction(e, { actingChild: childUIStateController });
35145
35480
  }
35146
35481
  },
35147
35482
  unregisterChild: (childUIStateController) => {
@@ -35203,7 +35538,19 @@ const useUIGroupStateController = (
35203
35538
  }
35204
35539
  onChange(e, { notifyExternal: true });
35205
35540
  },
35541
+ // The group was told yes, so what each of its parts shows is accepted
35542
+ // too: a group holds no value of its own — its rollback puts the
35543
+ // children back where they were (see resetUIState), and they can only
35544
+ // go back to what was acknowledged if they were told.
35545
+ acknowledgeUIState: () => {
35546
+ for (const c of childUIStateControllerArray) {
35547
+ if (shouldPropagateStateToChild(c)) {
35548
+ c.acknowledgeUIState();
35549
+ }
35550
+ }
35551
+ },
35206
35552
  onActionEnd: (e) => {
35553
+ acknowledgeOwnAction(controller);
35207
35554
  controller.rules.validation.syncValidity(e);
35208
35555
  },
35209
35556
  onActionError: (e) => {
@@ -35291,7 +35638,21 @@ const useUIGroupStateController = (
35291
35638
  // down last time. A popup reopened on another subject hands down the very
35292
35639
  // same empty value it did before, and the selection left inside it from
35293
35640
  // the previous opening is what has to go.
35294
- if (hasValueProp && !compareTwoJsValues(value, controller.uiState)) {
35641
+ //
35642
+ // Except while the group is asking about that very difference: the run in
35643
+ // flight carries what the user just chose, and the value is what the
35644
+ // owner holds until it answers. Putting the children back on it here
35645
+ // would take the choice off the screen on the first re-render — which the
35646
+ // run itself causes, by making the group busy — so the press would appear
35647
+ // to do nothing until the answer arrived, and the rollback on a refusal
35648
+ // would have nothing left to undo.
35649
+ const askingAboutIt =
35650
+ controller.actionInFlight || controller.queuedActionAllowedEvent;
35651
+ if (
35652
+ hasValueProp &&
35653
+ !askingAboutIt &&
35654
+ !compareTwoJsValues(value, controller.uiState)
35655
+ ) {
35295
35656
  placeChildrenFrom(value);
35296
35657
  }
35297
35658
  if (
@@ -37188,10 +37549,19 @@ const useInteractiveProps = (props, {
37188
37549
  // live source rather than from the rendered aria-busy, which conflates all
37189
37550
  // three and is a frame behind. See its own comment.
37190
37551
  uiStateController.loadingFromOwnProp = Boolean(loading);
37191
- uiStateController.loadingFromParent = loadingFromParent;
37552
+ // The group above waiting on something, which reaches every control in it:
37553
+ // each one is held for as long as that lasts, and "wait" is what they have
37554
+ // to say about it — not the read-only they wear to express it, which is a
37555
+ // settled word (see BUSY_CONSTRAINT and READONLY_CONSTRAINT).
37556
+ uiStateController.loadingFromAbove = Boolean(controlLoading);
37192
37557
  // Read by BUSY_CONSTRAINT: an optimistic control stays interactive while
37193
37558
  // its bound action runs (a new toggle replaces the run instead of waiting).
37194
37559
  uiStateController.optimistic = Boolean(optimistic);
37560
+ // What the interaction rule last refused is only true while the control is
37561
+ // held; the state it was read from moves here (see refreshReport).
37562
+ useLayoutEffect(() => {
37563
+ uiStateController.rules.interaction.refreshReport(new CustomEvent("interactivity_change"));
37564
+ }, [disabledResolved, readOnlyResolved, loadingResolved, controlLoading, uiStateController]);
37195
37565
  Object.assign(controlHostProps, {
37196
37566
  "required": requiredResolved,
37197
37567
  "aria-busy": loadingResolved ? "true" : "false",
@@ -37538,16 +37908,13 @@ const useInteractiveProps = (props, {
37538
37908
  event: e.detail.eventChain[0],
37539
37909
  name: "auto_group_action",
37540
37910
  requester: e.detail.requester,
37541
- // The interactivity gate is not re-asked: the user already
37542
- // interacted — with the child, whose gate said yes — and this
37543
- // follow-up is automatic. Asking again would also answer
37544
- // wrong: this event is dispatched inside the batch() that
37545
- // settles the child's action, where a bound action still
37546
- // READS as running (its state is mirrored through a signal
37547
- // effect the batch defers, see watchActionCompletion) — the
37548
- // busy constraint would refuse the group for an action that
37549
- // is already over. The validity gate still applies.
37550
- bypassInteractivity: true
37911
+ // Nobody asked for this one: it is the group's own continuation
37912
+ // of a press the child's gate already accepted. The gate still
37913
+ // decides — a group told to send by its own `command` is
37914
+ // already running that very action, and letting this through
37915
+ // would run it a second time — but a refusal here is not
37916
+ // reported, since the press it would answer went through.
37917
+ automatic: true
37551
37918
  });
37552
37919
  }
37553
37920
  }
@@ -56747,6 +57114,15 @@ const PopupClose = ({
56747
57114
  command: "--navi-close",
56748
57115
  icon: true,
56749
57116
  variant: "discrete"
57117
+ // Dismissing writes nothing to whatever control the popup belongs to, so
57118
+ // that control's read-only and disabled are not about this cross: a
57119
+ // read-only picker still opens to be read (see readonly_constraint.js),
57120
+ // and what is opened has to be closable. Whether closing is allowed at
57121
+ // this instant is the popup's own question, answered by the popup — a
57122
+ // dialog holds the close while an action inside it runs (see
57123
+ // findBusyElementInside in dialog.jsx).
57124
+ ,
57125
+ whenSelfInteractionsBlocked: "ignore"
56750
57126
  // The cross is drawn at the control size, which is a few millimetres
56751
57127
  // wide; the padding is what makes it a target a thumb can hit.
56752
57128
  ,
@@ -62011,6 +62387,42 @@ const ListItemFooter = props => {
62011
62387
  });
62012
62388
  };
62013
62389
 
62390
+ // What a row may say it is waiting on, all of them about the row as a thing
62391
+ // the LIST holds: joining it, leaving it, or being saved where it is. A
62392
+ // selectable row waiting on the list's own send is a different event with a
62393
+ // different subject — the selection, not the row (see list_selectable.jsx).
62394
+ const LOADING_REASON_SET = new Set(["adding", "removing", "updating"]);
62395
+
62396
+ /**
62397
+ * What a row waiting on something has to say when it is pressed. `loading` may
62398
+ * say WHAT it is waiting for: a row being created is not simply "busy", and
62399
+ * saying which one it is tells the user what to expect.
62400
+ *
62401
+ * Shared with the selectable row, which carries the same `loading` on the
62402
+ * hidden input holding its selection — the row and that control must answer a
62403
+ * press with one sentence, whichever of them catches it.
62404
+ */
62405
+ const listItemBusyMessage = (loading, props) => {
62406
+ if (LOADING_REASON_SET.has(loading)) {
62407
+ return naviI18n(`constraint.busy.item.${loading}`, props);
62408
+ }
62409
+ return naviI18n("constraint.busy.item", props);
62410
+ };
62411
+
62412
+ /**
62413
+ * Why the row cannot be acted on, in the row's own terms — or in the caller's,
62414
+ * where it gave them. The same two props every navi control answers a refusal
62415
+ * with (`readOnlyMessage`, `busyMessage`), so a row and the control it carries
62416
+ * cannot end up saying two different things about one press: whichever of them
62417
+ * catches it, the sentence is the same.
62418
+ */
62419
+ const listItemBlockedMessage = (loading, props) => {
62420
+ if (loading) {
62421
+ return props.busyMessage ?? listItemBusyMessage(loading, props);
62422
+ }
62423
+ return props.readOnlyMessage ?? naviI18n("constraint.readonly.item", props);
62424
+ };
62425
+
62014
62426
  installImportMetaCssBuild(import.meta);const css$y = /* css */`@layer navi {
62015
62427
  .navi_list_container[navi-selectable] {
62016
62428
  --list-item-outline-width: var(--navi-focus-outline-width);
@@ -62226,7 +62638,17 @@ const ListSelectable = props => {
62226
62638
  }
62227
62639
  return kept;
62228
62640
  };
62229
- const [listControlRootProps, listControlProps, childrenWrapperProps] = useControlgroupProps(props, {
62641
+ const [listControlRootProps, listControlProps, childrenWrapperProps] = useControlgroupProps({
62642
+ // The defaults of the group this IS (see CheckboxGroup / RadioGroup):
62643
+ // what a selectable list shows is a claim about what was accepted, so
62644
+ // a run that ends without an answer puts the rows back where they were
62645
+ // — a row left ticked over a refusal says the opposite of what
62646
+ // happened, and the error callout beside it does not untick it.
62647
+ resetOnCancel: true,
62648
+ resetOnAbort: true,
62649
+ resetOnError: true,
62650
+ ...props
62651
+ }, {
62230
62652
  stateType: multiple ? "array" : "",
62231
62653
  controlType: multiple ? "checkbox_group" : "radio_group",
62232
62654
  aggregateChildStates
@@ -62513,6 +62935,14 @@ const ListItemSelectable = props => {
62513
62935
  selected,
62514
62936
  pointed,
62515
62937
  selectableArea = "all",
62938
+ // True of the ROW as much as of the control it carries, so both are told.
62939
+ // Left in `rest` they would reach the hidden input alone — the control
62940
+ // system claims them (they are control props) and the row would never see
62941
+ // them, which for a row means no outline, no dimming, no press swallowed
62942
+ // and none of the buttons it contains held back (see ListItemReal). Any
62943
+ // prop a row and a control both understand belongs here.
62944
+ loading,
62945
+ readOnly,
62516
62946
  // The row's own click handler, kept out of the control props below: those
62517
62947
  // describe the hidden input that carries the selection, and a caller
62518
62948
  // writing onClick on a <List.Item> is talking about the row they see.
@@ -62523,23 +62953,33 @@ const ListItemSelectable = props => {
62523
62953
  // A checkbox toggles on its own; only a radio has to be told it may let go.
62524
62954
  const deselectable = useContext(SelectableListDeselectableContext) && !multiple;
62525
62955
  // Whose reason it is that this row cannot be taken. Read-only reaching it
62526
- // from above is the LIST's, and what is settled is then the whole answer —
62527
- // said as the selection where several things are taken, as the choice where
62528
- // one thing is. Said of each row in turn, "this option is not available"
62529
- // describes something else entirely: a list where each row happens to be
62530
- // unavailable for its own reasons, which goes on being said that way. Busy
62531
- // is not settled either: a list waiting on something says nothing about what
62532
- // will be possible once it is done, so the row keeps its own words.
62956
+ // from above is the LIST's, and that is the whole answer — said as the
62957
+ // selection where several things are taken, as the choice where one thing
62958
+ // is. Said of each row in turn, "this option is not available" describes
62959
+ // something else entirely: a list where each row happens to be unavailable
62960
+ // for its own reasons, which is what a row held on its own says.
62961
+ //
62962
+ // A list waiting on something is not unavailable, it is not answering yet —
62963
+ // busy rather than read-only (see BUSY_CONSTRAINT, which is asked first),
62964
+ // and the wait is about the same thing the read-only would be about.
62533
62965
  const readOnlyFromAbove = useContext(ReadOnlyContext);
62534
- const loadingFromAbove = useContext(LoadingContext$1);
62535
- const answerIsSettled = Boolean(readOnlyFromAbove) && !loadingFromAbove;
62536
- const readOnlyMessageKey = answerIsSettled ? multiple ? `constraint.readonly.selection` : `constraint.readonly.choice` : `constraint.readonly.option`;
62966
+ const readOnlyMessageKey = readOnlyFromAbove ? multiple ? `constraint.readonly.selection` : `constraint.readonly.choice` : `constraint.readonly.option`;
62967
+ // Handed to the row as well as to the control: whichever of the two catches
62968
+ // the press answers it, and they must not answer it differently.
62969
+ const readOnlyMessageResolved = props.readOnlyMessage ?? naviI18n(readOnlyMessageKey, props);
62970
+ const busyMessageResolved = props.busyMessage ?? (loading ? listItemBusyMessage(loading, props) : naviI18n(multiple ? `constraint.busy.selection` : `constraint.busy.choice`, props));
62537
62971
  const inputRef = useRef();
62538
62972
  const inputType = multiple ? "checkbox" : "radio";
62539
62973
  const inputId = `${id}_input`;
62540
62974
  inputRef.nullCanHappen = true; // virtualization
62541
62975
  const [checkableRootProps, checkableProps, controlChildrenWrapperProps] = useCheckableProps({
62542
- readOnlyMessage: naviI18n(readOnlyMessageKey, props),
62976
+ readOnlyMessage: readOnlyMessageResolved,
62977
+ // What the row itself is waiting for when it says so, the wait of the
62978
+ // list around it otherwise — said as the selection where several things
62979
+ // are taken, as the choice where one thing is.
62980
+ busyMessage: busyMessageResolved,
62981
+ loading,
62982
+ readOnly,
62543
62983
  ...rest,
62544
62984
  ref: inputRef,
62545
62985
  id: inputId,
@@ -62556,17 +62996,19 @@ const ListItemSelectable = props => {
62556
62996
  basePseudoState,
62557
62997
  children
62558
62998
  } = checkableProps;
62559
- const readOnly = basePseudoState[":read-only"];
62999
+ // Everything holding the control back, not only the row's own `readOnly`
63000
+ // above: the list above it, a maxLengthGuard with no room left.
63001
+ const readOnlyResolved = basePseudoState[":read-only"];
62560
63002
  const realInputContextValue = useMemo(() => {
62561
63003
  return {
62562
63004
  id: inputId,
62563
63005
  type: inputType,
62564
63006
  checked,
62565
- readOnly,
63007
+ readOnly: readOnlyResolved,
62566
63008
  value,
62567
63009
  deselectable
62568
63010
  };
62569
- }, [inputId, inputType, checked, readOnly, value, deselectable]);
63011
+ }, [inputId, inputType, checked, readOnlyResolved, value, deselectable]);
62570
63012
  return jsxs(Next, {
62571
63013
  id: id,
62572
63014
  index: index,
@@ -62588,6 +63030,10 @@ const ListItemSelectable = props => {
62588
63030
  },
62589
63031
  ref: props.ref,
62590
63032
  onClick: onClick,
63033
+ loading: loading,
63034
+ readOnly: readOnly,
63035
+ readOnlyMessage: readOnlyMessageResolved,
63036
+ busyMessage: busyMessageResolved,
62591
63037
  selectable: undefined,
62592
63038
  "navi-selectable-area-all": selectableArea === "all" ? "" : undefined,
62593
63039
  children: [jsx(SelectableRealInput, {
@@ -65755,13 +66201,30 @@ const ListItemReal = props => {
65755
66201
  if (event.button !== 0 || isOverlaidOnRow(event)) {
65756
66202
  return;
65757
66203
  }
65758
- blockInteraction(event);
65759
66204
  pressStartedHereRef.current = true;
66205
+ // A row that IS a control has one already able to answer this, in the same
66206
+ // words it uses for the keyboard, and it is the one the callout would be
66207
+ // anchored on either way — two answers would be two callouts on one anchor,
66208
+ // toggling each other off (see `reopen` in openCallout).
66209
+ //
66210
+ // Asked before the press is blocked, not after: a gate handed an event
66211
+ // already prevented reads it as "someone else took this one" and steps
66212
+ // back without a word (see onRequestInteraction).
66213
+ const controlHost = isControlRoot(event.currentTarget) ? findControlHost(event.currentTarget) : null;
66214
+ if (controlHost) {
66215
+ dispatchRequestInteraction(controlHost, {
66216
+ event,
66217
+ name: "press on a blocked row"
66218
+ });
66219
+ blockInteraction(event);
66220
+ return;
66221
+ }
66222
+ blockInteraction(event);
65760
66223
  // One at a time, and not the one that just dismissed it (see the refs).
65761
66224
  if (calloutRef.current && calloutRef.current.opened) {
65762
66225
  return;
65763
66226
  }
65764
- calloutRef.current = openCallout(blockedMessage(loading, readOnly, props), {
66227
+ calloutRef.current = openCallout(listItemBlockedMessage(loading, props), {
65765
66228
  anchorElement: event.currentTarget,
65766
66229
  status: "info",
65767
66230
  openingEvent: event
@@ -65788,6 +66251,11 @@ const ListItemReal = props => {
65788
66251
  ...itemColumnsOverrideProps,
65789
66252
  index: undefined,
65790
66253
  selected: undefined
66254
+ // Read by listItemBlockedMessage, not by the element.
66255
+ ,
66256
+
66257
+ readOnlyMessage: undefined,
66258
+ busyMessage: undefined
65791
66259
  // We use aria-hidden and not hidden because hidden would be forced to
65792
66260
  // display: none while here we want to keep it in the DOM to avoid layout shift
65793
66261
  // but visually hidden
@@ -65838,18 +66306,6 @@ const ListItemReal = props => {
65838
66306
  })]
65839
66307
  });
65840
66308
  };
65841
- // Why the row cannot be acted on, in the row's own terms. `loading` may say
65842
- // what it is waiting for ("adding", "removing"): a row being created is not
65843
- // simply "busy", and saying which one it is tells the user what to expect.
65844
- const blockedMessage = (loading, readOnly, props) => {
65845
- if (!loading) {
65846
- return naviI18n("constraint.readonly.item", props);
65847
- }
65848
- if (loading === "adding" || loading === "removing") {
65849
- return naviI18n(`constraint.busy.item.${loading}`, props);
65850
- }
65851
- return naviI18n("constraint.busy.item", props);
65852
- };
65853
66309
  const LIST_ITEM_STYLE_CSS_VARS = {
65854
66310
  "borderRadius": "--list-item-border-radius",
65855
66311
  "borderWidth": "--list-item-border-width",
@@ -65904,7 +66360,7 @@ const LIST_ITEM_STYLE_CSS_VARS = {
65904
66360
  * skeleton?: boolean,
65905
66361
  * error?: boolean | import("ignore:preact").ComponentChildren,
65906
66362
  * onErrorDismiss?: (event: Event) => void,
65907
- * loading?: boolean | "adding" | "removing",
66363
+ * loading?: boolean | "adding" | "removing" | "updating",
65908
66364
  * readOnly?: boolean,
65909
66365
  * filtered?: boolean,
65910
66366
  * hidden?: boolean,
@@ -65945,12 +66401,17 @@ const LIST_ITEM_STYLE_CSS_VARS = {
65945
66401
  * like the list's own error. `true` shows a generic sentence. When
65946
66402
  * `onErrorDismiss` is given, a dismiss button is drawn next to the message
65947
66403
  * and calls it.
65948
- * @param {boolean|"adding"|"removing"} [props.loading]
66404
+ * @param {boolean|"adding"|"removing"|"updating"} [props.loading]
65949
66405
  * The row is waiting on something: it draws a loading outline and, like
65950
66406
  * readOnly, stops taking clicks. Works on any item, not only a selectable
65951
- * one — a list is edited row by row. Pass "adding" or "removing" rather than
65952
- * true to say WHAT it is waiting for, which is what a press on it then
65953
- * answers.
66407
+ * one — a list is edited row by row. Rather than true, say WHAT it is
66408
+ * waiting for, which is what a press on it then answers: "adding" (joining
66409
+ * the list), "removing" (leaving it), "updating" (being saved where it is).
66410
+ *
66411
+ * All three are about the row as a thing the LIST holds, never about the
66412
+ * selection: a selectable row taken while its list sends says so on its own
66413
+ * ("la sélection est en cours d'enregistrement"), and needs no `loading` for
66414
+ * that.
65954
66415
  * @param {boolean} [props.readOnly]
65955
66416
  * The row cannot be acted on: dimmed and click-through-proof, buttons inside
65956
66417
  * it included.