@jsenv/navi 0.29.169 → 0.29.171

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);
@@ -4005,6 +4031,9 @@ const createAction = (callback, rootOptions = {}) => {
4005
4031
  * During the delay the instance is still the previous one, holding the
4006
4032
  * previous answer — `paramsSettlingSignal` is what says a newer one is
4007
4033
  * coming, and `useAsyncData(action, { loading: true })` reads it.
4034
+ * The instance follows where the signal settles, never a value it only
4035
+ * passed through: a signal back where it started before the delay elapses
4036
+ * asks nothing.
4008
4037
  */
4009
4038
  const bindParams = (newParamsOrSignal, options = {}) => {
4010
4039
  const { debounce, ...optionsWithoutDebounce } = options;
@@ -4900,7 +4929,9 @@ const runUnwatched = (startRun) => {
4900
4929
  * keystroke: set `debounce: 500` and the request is sent only after the user stops interacting
4901
4930
  * with the filters for 500 ms. During that delay the action still holds the previous answer;
4902
4931
  * `action.paramsSettlingSignal` is what says a newer one is coming, and
4903
- * `useAsyncData(action, { loading: true })` reads it.
4932
+ * `useAsyncData(action, { loading: true })` reads it. The run follows where
4933
+ * the params settle, never a value they only passed through: params back
4934
+ * where they started before the delay elapses run nothing.
4904
4935
  *
4905
4936
  * Example — auto-refresh a result list while the user tweaks filters:
4906
4937
  * ```js
@@ -5594,6 +5625,7 @@ const CONSTRAINT_NAME_TO_PROP = {
5594
5625
  min_special_char: "minSpecialCharMessage",
5595
5626
  one_of: "oneOfMessage",
5596
5627
  readonly: "readOnlyMessage",
5628
+ busy: "busyMessage",
5597
5629
  available: "availableMessage",
5598
5630
  };
5599
5631
 
@@ -6209,18 +6241,32 @@ const findControlProxies = (el) => {
6209
6241
  };
6210
6242
 
6211
6243
  /**
6212
- * Given a real control element, returns the proxy that visually represents it.
6244
+ * Given a real control element, returns the proxy a gesture on the control
6245
+ * lands on — the one thing said about the control is drawn on.
6213
6246
  *
6214
6247
  * Use when you need a single visible stand-in for the real control — anchoring
6215
6248
  * a callout, for instance. Anything notifying proxies of a state change wants
6216
6249
  * `findControlProxies` instead, so a control represented by several of them
6217
6250
  * updates all of them.
6218
6251
  *
6219
- * Returns `null` when no proxy exists for `el`.
6252
+ * A proxy the pointer cannot reach is drawn for the control without standing
6253
+ * for it: the surface around it takes the presses for everything it holds, so
6254
+ * that surface is where a refusal about the control belongs — not a box inside
6255
+ * it the finger never has to find. A selectable list row whose whole area is
6256
+ * the click target is exactly that arrangement (see `selectableArea`), and it
6257
+ * is read from the arrangement itself rather than from a flag repeating it:
6258
+ * where a gesture can land IS pointer-events.
6259
+ *
6260
+ * Returns `null` when no proxy stands for `el`.
6220
6261
  */
6221
6262
  const findControlProxy = (el) => {
6222
- const [firstProxy = null] = findControlProxies(el);
6223
- return firstProxy;
6263
+ for (const proxyElement of findControlProxies(el)) {
6264
+ if (getComputedStyle(proxyElement).pointerEvents === "none") {
6265
+ continue;
6266
+ }
6267
+ return proxyElement;
6268
+ }
6269
+ return null;
6224
6270
  };
6225
6271
 
6226
6272
  /**
@@ -8008,6 +8054,11 @@ const generateSvgWithoutArrow = (width, height) => {
8008
8054
  */
8009
8055
 
8010
8056
 
8057
+ // The close reason the manager gives itself when the callout has to be drawn on
8058
+ // another element: it tears the callout down and opens it again, and this is
8059
+ // what tells that apart from the callout being dismissed.
8060
+ const MOVING_TO_ANOTHER_ANCHOR = "moving_to_another_anchor";
8061
+
8011
8062
  /**
8012
8063
  * Creates an opaque token used as a key for callout open reasons.
8013
8064
  * Each caller (validation, interaction, …) owns one token.
@@ -8031,10 +8082,106 @@ const createCalloutManager = (
8031
8082
  const [notifyCalloutOpen, onCalloutOpen] = createPubSub();
8032
8083
 
8033
8084
  let callout = null;
8085
+ // Where the open callout is drawn. What a token points at is compared against
8086
+ // it to know whether showing that token is a new message on the same element
8087
+ // or the same conversation moving to another one.
8088
+ let calloutAnchorElement = null;
8034
8089
  // Tracks open tokens → their constraint info.
8035
8090
  // The callout closes automatically when the last token is removed.
8036
8091
  const tokens = new Map();
8037
8092
 
8093
+ const openCalloutForToken = (tokenData, event) => {
8094
+ const { anchorElement } = tokenData;
8095
+ calloutAnchorElement = anchorElement;
8096
+ const removeCloseOnCleanup = addTeardown?.(() => {
8097
+ requestCloseCallout(new CustomEvent("cleanup"), "cleanup");
8098
+ });
8099
+ // `openResults` is referenced in onClose which runs later — forward ref is intentional.
8100
+ let openResults = [];
8101
+ callout = openCallout(tokenData.message, {
8102
+ status: tokenData.status,
8103
+ testId: tokenData.testId,
8104
+ icon: tokenData.icon,
8105
+ closeButton: tokenData.closeButton,
8106
+ closeOnClickOutside: tokenData.status !== "error",
8107
+ anchorElement,
8108
+ openingEvent: event,
8109
+ skipFocus: tokenData.skipFocus,
8110
+ debug: debugPopup,
8111
+ onClose: ({
8112
+ event: closeEvent,
8113
+ reason,
8114
+ shouldTransferFocusFromCallout,
8115
+ }) => {
8116
+ removeCloseOnCleanup?.();
8117
+ for (const result of openResults) {
8118
+ if (typeof result === "function") {
8119
+ result();
8120
+ }
8121
+ }
8122
+ callout = null;
8123
+ calloutAnchorElement = null;
8124
+ if (reason === MOVING_TO_ANOTHER_ANCHOR) {
8125
+ // The control still has the same things to say, on another element:
8126
+ // the tokens stand and nothing was dismissed, so neither the token
8127
+ // callbacks nor the focus hand-back below apply.
8128
+ return;
8129
+ }
8130
+ // User dismissed the callout — notify all active tokens then clear.
8131
+ // Told what closed it: a token whose content is a popup of its own (a
8132
+ // picker in callout mode) closes that popup on the same event.
8133
+ for (const [, otherTokenData] of tokens) {
8134
+ otherTokenData.onClose?.({ event: closeEvent, reason });
8135
+ }
8136
+ tokens.clear();
8137
+ const element = controller.ref.current;
8138
+ if (
8139
+ shouldTransferFocusFromCallout &&
8140
+ element &&
8141
+ !element.closest('[aria-hidden="true"]')
8142
+ ) {
8143
+ const focusTarget =
8144
+ findFocusDelegateTarget(anchorElement) || anchorElement;
8145
+ debugFocus(
8146
+ closeEvent,
8147
+ `callout is closing with focus, give focus back to the control ${getElementSignature(focusTarget)}.focus()`,
8148
+ );
8149
+ focusTarget.focus();
8150
+ }
8151
+ },
8152
+ });
8153
+ openResults = notifyCalloutOpen(event);
8154
+ };
8155
+
8156
+ // What a token has to say, drawn where that token points. A control has one
8157
+ // callout, so a token takes it over from whoever held it — and a token about
8158
+ // another element (an action refused about the row that was pressed, on a
8159
+ // list that owns the action) moves it there, because a sentence about a row
8160
+ // drawn on the list is a sentence pointing at the wrong thing.
8161
+ const showToken = (tokenData, event) => {
8162
+ if (tokenData.anchorElement && !tokenData.anchorElement.isConnected) {
8163
+ // What the token pointed at has left the page — a row removed while its
8164
+ // refusal was still waiting behind another one. There is nothing left to
8165
+ // draw on there, and the control holding the token is still here, so it
8166
+ // takes it (same rule as an action error whose requester is gone, see
8167
+ // use_execute_action.js).
8168
+ tokenData.anchorElement = controller.ref.current;
8169
+ }
8170
+ if (callout) {
8171
+ if (tokenData.anchorElement === calloutAnchorElement) {
8172
+ callout.update(tokenData.message, {
8173
+ status: tokenData.status,
8174
+ testId: tokenData.testId,
8175
+ icon: tokenData.icon,
8176
+ closeButton: tokenData.closeButton,
8177
+ });
8178
+ return;
8179
+ }
8180
+ callout.requestClose(event, MOVING_TO_ANOTHER_ANCHOR);
8181
+ }
8182
+ openCalloutForToken(tokenData, event);
8183
+ };
8184
+
8038
8185
  // Remove a token. Closes the callout only when no tokens remain.
8039
8186
  // If other tokens are still active, updates the callout to show the first remaining one.
8040
8187
  const removeOpenToken = (token, event) => {
@@ -8045,12 +8192,7 @@ const createCalloutManager = (
8045
8192
  if (tokens.size > 0) {
8046
8193
  if (callout) {
8047
8194
  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
- });
8195
+ showToken(remainingTokenData, event);
8054
8196
  }
8055
8197
  return false;
8056
8198
  }
@@ -8087,70 +8229,21 @@ const createCalloutManager = (
8087
8229
  removeOpenToken(token, event);
8088
8230
  return;
8089
8231
  }
8090
- const calloutOptions = {
8232
+ const tokenData = {
8233
+ message,
8091
8234
  status,
8092
8235
  testId,
8093
8236
  icon,
8094
8237
  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
8238
  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);
8239
+ onClose,
8240
+ // Resolved as the token is added, and kept with it: a token shown later
8241
+ // (when the one covering it goes) must be drawn where it was pointing at
8242
+ // the time, and the control itself is where a token pointing nowhere goes.
8243
+ anchorElement: anchorElement || controller.ref.current,
8244
+ };
8245
+ tokens.set(token, tokenData);
8246
+ showToken(tokenData, event);
8154
8247
  };
8155
8248
 
8156
8249
  const calloutManager = {
@@ -8176,12 +8269,21 @@ const BUSY_CONSTRAINT = {
8176
8269
  transient: true,
8177
8270
  // Unlike readonly/disabled, a busy element DOES block its parent from
8178
8271
  // submitting — the element is mid-operation and cannot safely participate.
8179
- check: (field) => {
8272
+ check: (field, { intent } = {}) => {
8180
8273
  const isBusy = isControlBusy(field);
8181
8274
  if (!isBusy) {
8182
8275
  return null;
8183
8276
  }
8184
8277
 
8278
+ // Busy, and what it opens still opens: a picker's answer lives in a shape
8279
+ // only its popup draws, and refusing to open leaves it unreadable for as
8280
+ // long as the wait lasts. Opening reads and nothing more — the same
8281
+ // exemption read-only makes, on the same controls (see
8282
+ // READONLY_CONSTRAINT), so that "read" means one thing to both.
8283
+ if (intent === "read" && field.readOnlyOpens) {
8284
+ return null;
8285
+ }
8286
+
8185
8287
  const isButton = field.controlType === "button";
8186
8288
  const message = isButton
8187
8289
  ? naviI18n("constraint.busy.button")
@@ -8199,9 +8301,9 @@ CONSTRAINT_ATTRIBUTE_SET.add("data-busy");
8199
8301
  // whether it may finally close, a slide whether it may move on).
8200
8302
  //
8201
8303
  // 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.
8304
+ // control busy only because the group above is waiting has no state of its own
8305
+ // to read — the group's answer IS its answer, so it asks upward and inherits
8306
+ // the same live reading.
8205
8307
  const isControlBusy = (field) => {
8206
8308
  if (field.loadingFromOwnProp) {
8207
8309
  return true;
@@ -8225,7 +8327,7 @@ const isControlBusy = (field) => {
8225
8327
  return true;
8226
8328
  }
8227
8329
  }
8228
- if (field.loadingFromParent) {
8330
+ if (field.loadingFromAbove) {
8229
8331
  const parent = field.parentUIStateController;
8230
8332
  return parent ? isControlBusy(parent) : false;
8231
8333
  }
@@ -10063,6 +10165,9 @@ const createControlInteraction = (
10063
10165
  let failingManagedInteraction = null;
10064
10166
  // The title this rule put on the element, if any (see checkInteractivity).
10065
10167
  let titleWritten = null;
10168
+ // The constraint this control last refused a press with, while that refusal
10169
+ // is on screen — what refreshReport has to take back once it stops holding.
10170
+ let reportedConstraint = null;
10066
10171
 
10067
10172
  const checkInteractivity = ({ event, intent = "write" } = {}) => {
10068
10173
  interactionFailedConstraintInfo = null;
@@ -10153,20 +10258,50 @@ const createControlInteraction = (
10153
10258
 
10154
10259
  const canInteract =
10155
10260
  !interactionFailedConstraintInfo && !failingManagedInteraction;
10156
- // When the control is now interactable, remove the interaction token
10261
+ // When the control can be interacted with, remove the interaction token
10157
10262
  // so the callout closes if no other tokens (e.g. validation) are active.
10158
10263
  if (canInteract) {
10159
10264
  callout.removeOpenToken(INTERACTION_TOKEN, event);
10265
+ reportedConstraint = null;
10160
10266
  }
10161
10267
  return canInteract;
10162
10268
  };
10163
10269
 
10270
+ // What this rule says out loud is a LIVE reading, and nothing else comes back
10271
+ // to correct it: busy stops being true on its own (see BUSY_CONSTRAINT's
10272
+ // `transient`), and read-only inherited from a group goes with the run that
10273
+ // set it. Read again whenever the control's interactivity moves, so a refusal
10274
+ // is taken back once what it says stops being true — a callout left open over
10275
+ // a control it no longer describes also sits on top of it and swallows the
10276
+ // next press.
10277
+ //
10278
+ // Only when something WAS said: reading a control that has refused nothing
10279
+ // would start writing titles nobody asked for.
10280
+ const refreshReport = (event) => {
10281
+ const constraintReported = reportedConstraint;
10282
+ if (!constraintReported && titleWritten === null) {
10283
+ return;
10284
+ }
10285
+ checkInteractivity({ event });
10286
+ if (!reportedConstraint) {
10287
+ return;
10288
+ }
10289
+ if (interactionFailedConstraintInfo?.constraint === constraintReported) {
10290
+ return;
10291
+ }
10292
+ // Held still, but by something else — and the press that was answered is
10293
+ // long over, so it is taken back rather than answered a second time.
10294
+ callout.removeOpenToken(INTERACTION_TOKEN, event);
10295
+ reportedConstraint = null;
10296
+ };
10297
+
10164
10298
  const reportInteractivity = ({ event } = {}) => {
10165
10299
  if (failingManagedInteraction) {
10166
10300
  // Report on the specific child that is blocking, not the parent.
10167
10301
  failingManagedInteraction.reportInteractivity({ event });
10168
10302
  return;
10169
10303
  }
10304
+ reportedConstraint = interactionFailedConstraintInfo.constraint;
10170
10305
  debugInteraction(
10171
10306
  event,
10172
10307
  `reportInteractivity (${interactionFailedConstraintInfo.name})`,
@@ -10189,6 +10324,7 @@ const createControlInteraction = (
10189
10324
  const controlInteraction = {
10190
10325
  checkInteractivity,
10191
10326
  reportInteractivity,
10327
+ refreshReport,
10192
10328
  };
10193
10329
  Object.defineProperty(controlInteraction, "interactionFailedConstraintInfo", {
10194
10330
  get: () => interactionFailedConstraintInfo,
@@ -10237,6 +10373,10 @@ const onRequestInteraction = (
10237
10373
  // through (see READONLY_CONSTRAINT).
10238
10374
  intent = "write",
10239
10375
  bypassInteractivity = false,
10376
+ // navi continuing a gesture on its own rather than a user asking for
10377
+ // something: the decision still applies, the explanation does not (see
10378
+ // below).
10379
+ automatic = false,
10240
10380
  // Who asked, when the control is not answering the gesture on its own —
10241
10381
  // the source of a command (see the self-interactions step-back below).
10242
10382
  requester,
@@ -10297,7 +10437,16 @@ const onRequestInteraction = (
10297
10437
  const reason = failedInfo
10298
10438
  ? `failing interaction constraint "${failedInfo.name}"`
10299
10439
  : "not interactable";
10300
- ci.reportInteractivity({ event });
10440
+ // A refusal is explained to whoever asked, and an automatic follow-up
10441
+ // has nobody waiting for one: a group whose action a part just asked
10442
+ // for is busy running exactly that (see auto_group_action in
10443
+ // control_hooks.jsx), so the follow-up is turned down — which is what
10444
+ // keeps the action from running twice — while the press that started
10445
+ // it went through. Saying "this element is busy" there answers an
10446
+ // accepted press with a refusal.
10447
+ if (!automatic) {
10448
+ ci.reportInteractivity({ event });
10449
+ }
10301
10450
  onPrevented(reason);
10302
10451
  return false;
10303
10452
  }
@@ -10351,6 +10500,11 @@ const dispatchRequestAction = (
10351
10500
  {
10352
10501
  event,
10353
10502
  name = "dispatchRequestAction",
10503
+ // The gate's own options, named here so they reach it: everything left in
10504
+ // actionOptions goes to the action alone, so a gate option arriving under
10505
+ // any other name is dropped without a word.
10506
+ automatic,
10507
+ bypassInteractivity,
10354
10508
  prevented,
10355
10509
  allowed,
10356
10510
  always,
@@ -10360,6 +10514,8 @@ const dispatchRequestAction = (
10360
10514
  return dispatchRequestInteraction(element, {
10361
10515
  event,
10362
10516
  name,
10517
+ automatic,
10518
+ bypassInteractivity,
10363
10519
  // The gate needs it as much as the action does: the requester may be an
10364
10520
  // affordance that claimed the press to ask for this very action, and the
10365
10521
  // control must not read that claim as "this press was not for me" — see
@@ -12965,9 +13121,10 @@ const useExecuteAction = (
12965
13121
  }, [error]);
12966
13122
 
12967
13123
  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.
13124
+ // The error is stored on the element that owns the action (the form/element
13125
+ // itself). The requester — the submit button that sent, the row of a list
13126
+ // whose command sent — is stored as the callout display target, so the
13127
+ // message appears on whoever asked rather than on the whole control.
12971
13128
  const element = elementRef.current;
12972
13129
  let target = requester;
12973
13130
  // A requester that is no longer on the page is not a place to show
@@ -31300,6 +31457,12 @@ const anyMatchingRouteSignal = (routes) => {
31300
31457
  * @param {Element} [options.anchor] Where a popup this opens should be placed,
31301
31458
  * when that is not the element asking: a menu opened by a press belongs at the
31302
31459
  * point the press happened, and the row that was pressed is not that point.
31460
+ * @param {Element} [options.requester] Who asked, when that is not the source.
31461
+ * A group triggers its own command from itself — what the command aims at is
31462
+ * read around the group, not around the child — while the gesture was on one
31463
+ * of its children, and what a refusal is about is that child. Same role as
31464
+ * the submit button a form's send names: the action belongs to the control,
31465
+ * the answer is drawn on whoever asked for it.
31303
31466
  */
31304
31467
  const triggerNaviCommand = (element, command, event, options) => {
31305
31468
  const run = resolveNaviCommand(element, command, event, options);
@@ -31330,7 +31493,7 @@ const resolveNaviCommand = (
31330
31493
  element,
31331
31494
  command,
31332
31495
  event,
31333
- { optional, value, anchor } = {},
31496
+ { optional, value, anchor, requester } = {},
31334
31497
  ) => {
31335
31498
  if (!event) {
31336
31499
  throw new Error(
@@ -31359,6 +31522,7 @@ const resolveNaviCommand = (
31359
31522
  // source: the attribute form has a `value` to read (`<Button value={id}>`),
31360
31523
  // a JS decision has none, and both must be able to say the same thing.
31361
31524
  value,
31525
+ requester,
31362
31526
  });
31363
31527
  if (!execute) {
31364
31528
  if (optional) {
@@ -31542,7 +31706,8 @@ const onNaviCommand = (e, { debugCommand = () => {} } = {}) => {
31542
31706
  };
31543
31707
 
31544
31708
  const NAVI_COMMANDS = {};
31545
- // commandHandler(source, event) → { target, implementation } | undefined
31709
+ // commandHandler(source, event, { argument, anchor, value, requester })
31710
+ // → { target, implementation } | undefined
31546
31711
  // - Each handler calls resolveExplicitTarget(source) first, then falls back to
31547
31712
  // its own DOM resolution logic (closest expandable, parent control, etc.).
31548
31713
  // - Returns undefined when no target can be found — this is a normal outcome for
@@ -31780,7 +31945,7 @@ const resolveAfterSend = (target, requester) => {
31780
31945
  return undefined;
31781
31946
  };
31782
31947
 
31783
- registerNaviCommand("--navi-send", (source, event) => {
31948
+ registerNaviCommand("--navi-send", (source, event, { requester }) => {
31784
31949
  const expandable = resolveExpandableAround(source);
31785
31950
  const target =
31786
31951
  resolveExplicitTarget(source) ||
@@ -31823,14 +31988,21 @@ registerNaviCommand("--navi-send", (source, event) => {
31823
31988
  return {
31824
31989
  target,
31825
31990
  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;
31991
+ // Who asked, taken from whoever triggered the command when it said so —
31992
+ // a group sends from itself and names the child the gesture was on (see
31993
+ // triggerNaviCommand's `requester`). Named means named: the stand-in
31994
+ // below is for a source nobody spoke for.
31995
+ let sendRequester = requester;
31996
+ if (!sendRequester) {
31997
+ sendRequester = source;
31998
+ if (!source.matches(submitSelector)) {
31999
+ // When present, use the first submit button as the requester, not the input.
32000
+ // This aligns with browser behavior where Enter in a text input triggers
32001
+ // the first submit button of the form, not the input itself.
32002
+ const firstButtonSubmitting = target.querySelector(submitSelector);
32003
+ if (firstButtonSubmitting) {
32004
+ sendRequester = firstButtonSubmitting;
32005
+ }
31834
32006
  }
31835
32007
  }
31836
32008
  // Nothing is committed when a constraint fails, so nothing is decided
@@ -31844,7 +32016,7 @@ registerNaviCommand("--navi-send", (source, event) => {
31844
32016
  // from the response writes it on the form while it runs
31845
32017
  // (data-after-send), and this is what picks it up.
31846
32018
  const runAfterSend = () => {
31847
- const afterSend = resolveAfterSend(target, requester);
32019
+ const afterSend = resolveAfterSend(target, sendRequester);
31848
32020
  if (!afterSend) {
31849
32021
  return;
31850
32022
  }
@@ -31879,7 +32051,7 @@ registerNaviCommand("--navi-send", (source, event) => {
31879
32051
  initiator.preventDefault();
31880
32052
  }
31881
32053
  },
31882
- requester,
32054
+ requester: sendRequester,
31883
32055
  }),
31884
32056
  );
31885
32057
  if (sent === false || invalid) {
@@ -34140,8 +34312,20 @@ const useUIStateController = (
34140
34312
  resetUIState: (e) => {
34141
34313
  controller.setUIState(controller.state, e);
34142
34314
  },
34315
+ // What the control shows becomes what is acknowledged: the outside said
34316
+ // yes to it, so it is where a rollback goes back to. Left alone for a
34317
+ // control whose value is GIVEN — the prop is already its truth — and
34318
+ // while a newer request waits behind this one, whose own answer is what
34319
+ // will settle what is accepted.
34320
+ acknowledgeUIState: () => {
34321
+ if (controller.hasStateProp || controller.queuedActionAllowedEvent) {
34322
+ return;
34323
+ }
34324
+ controller.state = controller.uiState;
34325
+ },
34143
34326
  onActionEnd: (e) => {
34144
34327
  debugUIState(`"${controlType}" actionEnd called`);
34328
+ acknowledgeOwnAction(controller);
34145
34329
  controller.rules.validation.syncValidity(e);
34146
34330
  },
34147
34331
  onActionError: (e) => {
@@ -34351,6 +34535,18 @@ const firstDefinedChildUIState = (children) => {
34351
34535
  return undefined;
34352
34536
  };
34353
34537
 
34538
+ // A run that came back with a yes settles what the control is worth — but only
34539
+ // for the control the run BELONGS to. Anything with a `command` still fires its
34540
+ // own navi_action_* events (it is given a placeholder action so they exist),
34541
+ // and taking those for an acknowledgement is how a row would remember its own
34542
+ // selection as accepted and hand it back on the group's rollback.
34543
+ const acknowledgeOwnAction = (controller) => {
34544
+ if (!controller.props.action) {
34545
+ return;
34546
+ }
34547
+ controller.acknowledgeUIState();
34548
+ };
34549
+
34354
34550
  // Default aggregate/distribute implementations keyed by controlType or stateType.
34355
34551
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
34356
34552
  // distributeChildUIState. If neither a default nor an explicit impl is found for a
@@ -34779,12 +34975,15 @@ const useUIGroupStateController = (
34779
34975
  if (notifyExternal === true || notifyExternal === "if-it-moves") {
34780
34976
  if (
34781
34977
  notifyExternal === "if-it-moves" &&
34782
- compareTwoJsValues(groupUIState, controller.uiState)
34978
+ compareTwoJsValues(groupUIState, controller.announcedUIState)
34783
34979
  ) {
34784
- // A child confirming what it already shows, to a group already
34785
- // worth it: the value arrived while the popup was open and the
34786
- // group reacted then. One trip through a picker is one answer, so
34787
- // there is nothing left to say here.
34980
+ // A child confirming what it already shows, to a group that has
34981
+ // already said it: the value arrived while the popup was open and
34982
+ // the group reacted then. One trip through a picker is one answer,
34983
+ // so there is nothing left to say here. Measured against what was
34984
+ // announced rather than against what the group holds — a picker
34985
+ // mounting into a form folds its suggestion into that silently, and
34986
+ // a value nobody was ever told is not one that has been said.
34788
34987
  return;
34789
34988
  }
34790
34989
  // Somebody answered: what the group is worth is what its children say
@@ -34815,7 +35014,7 @@ const useUIGroupStateController = (
34815
35014
  { except: actingChild },
34816
35015
  );
34817
35016
  }
34818
- applyState(groupUIState, e);
35017
+ applyState(groupUIState, e, { actingChild });
34819
35018
  } else if (notifyExternal === "silent") {
34820
35019
  controller.syncInternalState(groupUIState);
34821
35020
  s.parentUIStateController?.onChildUIAction(controller, e, {
@@ -34869,10 +35068,19 @@ const useUIGroupStateController = (
34869
35068
  childUIStateController.setUIState(childNewState, e);
34870
35069
  };
34871
35070
 
34872
- const applyState = (newUIState, e, { internalBehavior = false } = {}) => {
35071
+ const applyState = (
35072
+ newUIState,
35073
+ e,
35074
+ { internalBehavior = false, actingChild } = {},
35075
+ ) => {
34873
35076
  const { controller } = s;
34874
35077
  const currentUIState = controller.uiState;
34875
35078
  controller.uiState = newUIState;
35079
+ // What the outside was last told, which is not what the group is worth:
35080
+ // a child mounting into a group that derived its own value moves that
35081
+ // value through syncInternalState, deliberately without telling anybody
35082
+ // (see onChange). Only what goes through here has been said out loud.
35083
+ controller.announcedUIState = newUIState;
34876
35084
  uiStateSignal.value = newUIState;
34877
35085
  debugUIGroup(
34878
35086
  e,
@@ -34885,7 +35093,10 @@ const useUIGroupStateController = (
34885
35093
  s.parentUIStateController?.onChildUIAction(controller, e, {
34886
35094
  stateChanged: true,
34887
35095
  });
34888
- controller.onUIAction(e, { skipCommand: internalBehavior });
35096
+ controller.onUIAction(e, {
35097
+ skipCommand: internalBehavior,
35098
+ actingChild,
35099
+ });
34889
35100
  const el = controller.ref.current;
34890
35101
  if (el) {
34891
35102
  dispatchInternalCustomEvent(el, "navi_ui_state_change", {
@@ -34905,6 +35116,7 @@ const useUIGroupStateController = (
34905
35116
  hasDefaultValueProp,
34906
35117
  props,
34907
35118
  uiState: stateInitial,
35119
+ announcedUIState: stateInitial,
34908
35120
  // Whether what the group holds was HANDED to it (a parent distributing,
34909
35121
  // a picker filling its popup, a value prop) rather than worked out from
34910
35122
  // its children. What it protects is read in onChange.
@@ -35023,7 +35235,7 @@ const useUIGroupStateController = (
35023
35235
  uiStateSignal.value = newUIState;
35024
35236
  publishUIState(newUIState);
35025
35237
  },
35026
- onUIAction: (e, { skipCommand } = {}) => {
35238
+ onUIAction: (e, { skipCommand, actingChild } = {}) => {
35027
35239
  const currentUIState = controller.uiState;
35028
35240
  // The same write applyState/onChange make (see writeBoundSignal), for
35029
35241
  // the case where the state did not move but the user acted all the
@@ -35037,7 +35249,14 @@ const useUIGroupStateController = (
35037
35249
  if (!skipCommand && controller.props.command) {
35038
35250
  const el = controller.ref.current;
35039
35251
  if (el) {
35040
- triggerNaviCommand(el, controller.props.command, e);
35252
+ // The command is the GROUP's — what it aims at is read around the
35253
+ // group, not around the child — but the child is who asked, and a
35254
+ // refusal is drawn on whoever asked (see triggerNaviCommand's
35255
+ // `requester`). Without it a list refusing a name says so at its
35256
+ // own foot, rows away from the name that was touched.
35257
+ triggerNaviCommand(el, controller.props.command, e, {
35258
+ requester: actingChild?.ref.current,
35259
+ });
35041
35260
  }
35042
35261
  }
35043
35262
  },
@@ -35132,7 +35351,7 @@ const useUIGroupStateController = (
35132
35351
  actingChild: childUIStateController,
35133
35352
  });
35134
35353
  } else {
35135
- controller.onUIAction(e);
35354
+ controller.onUIAction(e, { actingChild: childUIStateController });
35136
35355
  }
35137
35356
  },
35138
35357
  unregisterChild: (childUIStateController) => {
@@ -35194,7 +35413,19 @@ const useUIGroupStateController = (
35194
35413
  }
35195
35414
  onChange(e, { notifyExternal: true });
35196
35415
  },
35416
+ // The group was told yes, so what each of its parts shows is accepted
35417
+ // too: a group holds no value of its own — its rollback puts the
35418
+ // children back where they were (see resetUIState), and they can only
35419
+ // go back to what was acknowledged if they were told.
35420
+ acknowledgeUIState: () => {
35421
+ for (const c of childUIStateControllerArray) {
35422
+ if (shouldPropagateStateToChild(c)) {
35423
+ c.acknowledgeUIState();
35424
+ }
35425
+ }
35426
+ },
35197
35427
  onActionEnd: (e) => {
35428
+ acknowledgeOwnAction(controller);
35198
35429
  controller.rules.validation.syncValidity(e);
35199
35430
  },
35200
35431
  onActionError: (e) => {
@@ -35282,7 +35513,21 @@ const useUIGroupStateController = (
35282
35513
  // down last time. A popup reopened on another subject hands down the very
35283
35514
  // same empty value it did before, and the selection left inside it from
35284
35515
  // the previous opening is what has to go.
35285
- if (hasValueProp && !compareTwoJsValues(value, controller.uiState)) {
35516
+ //
35517
+ // Except while the group is asking about that very difference: the run in
35518
+ // flight carries what the user just chose, and the value is what the
35519
+ // owner holds until it answers. Putting the children back on it here
35520
+ // would take the choice off the screen on the first re-render — which the
35521
+ // run itself causes, by making the group busy — so the press would appear
35522
+ // to do nothing until the answer arrived, and the rollback on a refusal
35523
+ // would have nothing left to undo.
35524
+ const askingAboutIt =
35525
+ controller.actionInFlight || controller.queuedActionAllowedEvent;
35526
+ if (
35527
+ hasValueProp &&
35528
+ !askingAboutIt &&
35529
+ !compareTwoJsValues(value, controller.uiState)
35530
+ ) {
35286
35531
  placeChildrenFrom(value);
35287
35532
  }
35288
35533
  if (
@@ -37179,10 +37424,19 @@ const useInteractiveProps = (props, {
37179
37424
  // live source rather than from the rendered aria-busy, which conflates all
37180
37425
  // three and is a frame behind. See its own comment.
37181
37426
  uiStateController.loadingFromOwnProp = Boolean(loading);
37182
- uiStateController.loadingFromParent = loadingFromParent;
37427
+ // The group above waiting on something, which reaches every control in it:
37428
+ // each one is held for as long as that lasts, and "wait" is what they have
37429
+ // to say about it — not the read-only they wear to express it, which is a
37430
+ // settled word (see BUSY_CONSTRAINT and READONLY_CONSTRAINT).
37431
+ uiStateController.loadingFromAbove = Boolean(controlLoading);
37183
37432
  // Read by BUSY_CONSTRAINT: an optimistic control stays interactive while
37184
37433
  // its bound action runs (a new toggle replaces the run instead of waiting).
37185
37434
  uiStateController.optimistic = Boolean(optimistic);
37435
+ // What the interaction rule last refused is only true while the control is
37436
+ // held; the state it was read from moves here (see refreshReport).
37437
+ useLayoutEffect(() => {
37438
+ uiStateController.rules.interaction.refreshReport(new CustomEvent("interactivity_change"));
37439
+ }, [disabledResolved, readOnlyResolved, loadingResolved, controlLoading, uiStateController]);
37186
37440
  Object.assign(controlHostProps, {
37187
37441
  "required": requiredResolved,
37188
37442
  "aria-busy": loadingResolved ? "true" : "false",
@@ -37529,16 +37783,13 @@ const useInteractiveProps = (props, {
37529
37783
  event: e.detail.eventChain[0],
37530
37784
  name: "auto_group_action",
37531
37785
  requester: e.detail.requester,
37532
- // The interactivity gate is not re-asked: the user already
37533
- // interacted — with the child, whose gate said yes — and this
37534
- // follow-up is automatic. Asking again would also answer
37535
- // wrong: this event is dispatched inside the batch() that
37536
- // settles the child's action, where a bound action still
37537
- // READS as running (its state is mirrored through a signal
37538
- // effect the batch defers, see watchActionCompletion) — the
37539
- // busy constraint would refuse the group for an action that
37540
- // is already over. The validity gate still applies.
37541
- bypassInteractivity: true
37786
+ // Nobody asked for this one: it is the group's own continuation
37787
+ // of a press the child's gate already accepted. The gate still
37788
+ // decides — a group told to send by its own `command` is
37789
+ // already running that very action, and letting this through
37790
+ // would run it a second time — but a refusal here is not
37791
+ // reported, since the press it would answer went through.
37792
+ automatic: true
37542
37793
  });
37543
37794
  }
37544
37795
  }
@@ -41940,10 +42191,8 @@ const css$W = /* css */`@layer navi {
41940
42191
  }
41941
42192
  }
41942
42193
 
41943
- &[data-expand-x] {
41944
- flex-grow: 1;
41945
-
41946
- &:not([data-vertical]):not([data-scrollable]) .navi_link {
42194
+ &[data-expand-x]:not([data-vertical]):not([data-scrollable]) {
42195
+ & .navi_link {
41947
42196
  flex: 1;
41948
42197
  justify-content: center;
41949
42198
  }
@@ -62004,6 +62253,42 @@ const ListItemFooter = props => {
62004
62253
  });
62005
62254
  };
62006
62255
 
62256
+ // What a row may say it is waiting on, all of them about the row as a thing
62257
+ // the LIST holds: joining it, leaving it, or being saved where it is. A
62258
+ // selectable row waiting on the list's own send is a different event with a
62259
+ // different subject — the selection, not the row (see list_selectable.jsx).
62260
+ const LOADING_REASON_SET = new Set(["adding", "removing", "updating"]);
62261
+
62262
+ /**
62263
+ * What a row waiting on something has to say when it is pressed. `loading` may
62264
+ * say WHAT it is waiting for: a row being created is not simply "busy", and
62265
+ * saying which one it is tells the user what to expect.
62266
+ *
62267
+ * Shared with the selectable row, which carries the same `loading` on the
62268
+ * hidden input holding its selection — the row and that control must answer a
62269
+ * press with one sentence, whichever of them catches it.
62270
+ */
62271
+ const listItemBusyMessage = (loading, props) => {
62272
+ if (LOADING_REASON_SET.has(loading)) {
62273
+ return naviI18n(`constraint.busy.item.${loading}`, props);
62274
+ }
62275
+ return naviI18n("constraint.busy.item", props);
62276
+ };
62277
+
62278
+ /**
62279
+ * Why the row cannot be acted on, in the row's own terms — or in the caller's,
62280
+ * where it gave them. The same two props every navi control answers a refusal
62281
+ * with (`readOnlyMessage`, `busyMessage`), so a row and the control it carries
62282
+ * cannot end up saying two different things about one press: whichever of them
62283
+ * catches it, the sentence is the same.
62284
+ */
62285
+ const listItemBlockedMessage = (loading, props) => {
62286
+ if (loading) {
62287
+ return props.busyMessage ?? listItemBusyMessage(loading, props);
62288
+ }
62289
+ return props.readOnlyMessage ?? naviI18n("constraint.readonly.item", props);
62290
+ };
62291
+
62007
62292
  installImportMetaCssBuild(import.meta);const css$y = /* css */`@layer navi {
62008
62293
  .navi_list_container[navi-selectable] {
62009
62294
  --list-item-outline-width: var(--navi-focus-outline-width);
@@ -62219,7 +62504,17 @@ const ListSelectable = props => {
62219
62504
  }
62220
62505
  return kept;
62221
62506
  };
62222
- const [listControlRootProps, listControlProps, childrenWrapperProps] = useControlgroupProps(props, {
62507
+ const [listControlRootProps, listControlProps, childrenWrapperProps] = useControlgroupProps({
62508
+ // The defaults of the group this IS (see CheckboxGroup / RadioGroup):
62509
+ // what a selectable list shows is a claim about what was accepted, so
62510
+ // a run that ends without an answer puts the rows back where they were
62511
+ // — a row left ticked over a refusal says the opposite of what
62512
+ // happened, and the error callout beside it does not untick it.
62513
+ resetOnCancel: true,
62514
+ resetOnAbort: true,
62515
+ resetOnError: true,
62516
+ ...props
62517
+ }, {
62223
62518
  stateType: multiple ? "array" : "",
62224
62519
  controlType: multiple ? "checkbox_group" : "radio_group",
62225
62520
  aggregateChildStates
@@ -62506,6 +62801,14 @@ const ListItemSelectable = props => {
62506
62801
  selected,
62507
62802
  pointed,
62508
62803
  selectableArea = "all",
62804
+ // True of the ROW as much as of the control it carries, so both are told.
62805
+ // Left in `rest` they would reach the hidden input alone — the control
62806
+ // system claims them (they are control props) and the row would never see
62807
+ // them, which for a row means no outline, no dimming, no press swallowed
62808
+ // and none of the buttons it contains held back (see ListItemReal). Any
62809
+ // prop a row and a control both understand belongs here.
62810
+ loading,
62811
+ readOnly,
62509
62812
  // The row's own click handler, kept out of the control props below: those
62510
62813
  // describe the hidden input that carries the selection, and a caller
62511
62814
  // writing onClick on a <List.Item> is talking about the row they see.
@@ -62516,23 +62819,33 @@ const ListItemSelectable = props => {
62516
62819
  // A checkbox toggles on its own; only a radio has to be told it may let go.
62517
62820
  const deselectable = useContext(SelectableListDeselectableContext) && !multiple;
62518
62821
  // Whose reason it is that this row cannot be taken. Read-only reaching it
62519
- // from above is the LIST's, and what is settled is then the whole answer —
62520
- // said as the selection where several things are taken, as the choice where
62521
- // one thing is. Said of each row in turn, "this option is not available"
62522
- // describes something else entirely: a list where each row happens to be
62523
- // unavailable for its own reasons, which goes on being said that way. Busy
62524
- // is not settled either: a list waiting on something says nothing about what
62525
- // will be possible once it is done, so the row keeps its own words.
62822
+ // from above is the LIST's, and that is the whole answer — said as the
62823
+ // selection where several things are taken, as the choice where one thing
62824
+ // is. Said of each row in turn, "this option is not available" describes
62825
+ // something else entirely: a list where each row happens to be unavailable
62826
+ // for its own reasons, which is what a row held on its own says.
62827
+ //
62828
+ // A list waiting on something is not unavailable, it is not answering yet —
62829
+ // busy rather than read-only (see BUSY_CONSTRAINT, which is asked first),
62830
+ // and the wait is about the same thing the read-only would be about.
62526
62831
  const readOnlyFromAbove = useContext(ReadOnlyContext);
62527
- const loadingFromAbove = useContext(LoadingContext$1);
62528
- const answerIsSettled = Boolean(readOnlyFromAbove) && !loadingFromAbove;
62529
- const readOnlyMessageKey = answerIsSettled ? multiple ? `constraint.readonly.selection` : `constraint.readonly.choice` : `constraint.readonly.option`;
62832
+ const readOnlyMessageKey = readOnlyFromAbove ? multiple ? `constraint.readonly.selection` : `constraint.readonly.choice` : `constraint.readonly.option`;
62833
+ // Handed to the row as well as to the control: whichever of the two catches
62834
+ // the press answers it, and they must not answer it differently.
62835
+ const readOnlyMessageResolved = props.readOnlyMessage ?? naviI18n(readOnlyMessageKey, props);
62836
+ const busyMessageResolved = props.busyMessage ?? (loading ? listItemBusyMessage(loading, props) : naviI18n(multiple ? `constraint.busy.selection` : `constraint.busy.choice`, props));
62530
62837
  const inputRef = useRef();
62531
62838
  const inputType = multiple ? "checkbox" : "radio";
62532
62839
  const inputId = `${id}_input`;
62533
62840
  inputRef.nullCanHappen = true; // virtualization
62534
62841
  const [checkableRootProps, checkableProps, controlChildrenWrapperProps] = useCheckableProps({
62535
- readOnlyMessage: naviI18n(readOnlyMessageKey, props),
62842
+ readOnlyMessage: readOnlyMessageResolved,
62843
+ // What the row itself is waiting for when it says so, the wait of the
62844
+ // list around it otherwise — said as the selection where several things
62845
+ // are taken, as the choice where one thing is.
62846
+ busyMessage: busyMessageResolved,
62847
+ loading,
62848
+ readOnly,
62536
62849
  ...rest,
62537
62850
  ref: inputRef,
62538
62851
  id: inputId,
@@ -62549,17 +62862,19 @@ const ListItemSelectable = props => {
62549
62862
  basePseudoState,
62550
62863
  children
62551
62864
  } = checkableProps;
62552
- const readOnly = basePseudoState[":read-only"];
62865
+ // Everything holding the control back, not only the row's own `readOnly`
62866
+ // above: the list above it, a maxLengthGuard with no room left.
62867
+ const readOnlyResolved = basePseudoState[":read-only"];
62553
62868
  const realInputContextValue = useMemo(() => {
62554
62869
  return {
62555
62870
  id: inputId,
62556
62871
  type: inputType,
62557
62872
  checked,
62558
- readOnly,
62873
+ readOnly: readOnlyResolved,
62559
62874
  value,
62560
62875
  deselectable
62561
62876
  };
62562
- }, [inputId, inputType, checked, readOnly, value, deselectable]);
62877
+ }, [inputId, inputType, checked, readOnlyResolved, value, deselectable]);
62563
62878
  return jsxs(Next, {
62564
62879
  id: id,
62565
62880
  index: index,
@@ -62581,6 +62896,10 @@ const ListItemSelectable = props => {
62581
62896
  },
62582
62897
  ref: props.ref,
62583
62898
  onClick: onClick,
62899
+ loading: loading,
62900
+ readOnly: readOnly,
62901
+ readOnlyMessage: readOnlyMessageResolved,
62902
+ busyMessage: busyMessageResolved,
62584
62903
  selectable: undefined,
62585
62904
  "navi-selectable-area-all": selectableArea === "all" ? "" : undefined,
62586
62905
  children: [jsx(SelectableRealInput, {
@@ -65754,7 +66073,7 @@ const ListItemReal = props => {
65754
66073
  if (calloutRef.current && calloutRef.current.opened) {
65755
66074
  return;
65756
66075
  }
65757
- calloutRef.current = openCallout(blockedMessage(loading, readOnly, props), {
66076
+ calloutRef.current = openCallout(listItemBlockedMessage(loading, props), {
65758
66077
  anchorElement: event.currentTarget,
65759
66078
  status: "info",
65760
66079
  openingEvent: event
@@ -65781,6 +66100,11 @@ const ListItemReal = props => {
65781
66100
  ...itemColumnsOverrideProps,
65782
66101
  index: undefined,
65783
66102
  selected: undefined
66103
+ // Read by listItemBlockedMessage, not by the element.
66104
+ ,
66105
+
66106
+ readOnlyMessage: undefined,
66107
+ busyMessage: undefined
65784
66108
  // We use aria-hidden and not hidden because hidden would be forced to
65785
66109
  // display: none while here we want to keep it in the DOM to avoid layout shift
65786
66110
  // but visually hidden
@@ -65831,18 +66155,6 @@ const ListItemReal = props => {
65831
66155
  })]
65832
66156
  });
65833
66157
  };
65834
- // Why the row cannot be acted on, in the row's own terms. `loading` may say
65835
- // what it is waiting for ("adding", "removing"): a row being created is not
65836
- // simply "busy", and saying which one it is tells the user what to expect.
65837
- const blockedMessage = (loading, readOnly, props) => {
65838
- if (!loading) {
65839
- return naviI18n("constraint.readonly.item", props);
65840
- }
65841
- if (loading === "adding" || loading === "removing") {
65842
- return naviI18n(`constraint.busy.item.${loading}`, props);
65843
- }
65844
- return naviI18n("constraint.busy.item", props);
65845
- };
65846
66158
  const LIST_ITEM_STYLE_CSS_VARS = {
65847
66159
  "borderRadius": "--list-item-border-radius",
65848
66160
  "borderWidth": "--list-item-border-width",
@@ -65897,7 +66209,7 @@ const LIST_ITEM_STYLE_CSS_VARS = {
65897
66209
  * skeleton?: boolean,
65898
66210
  * error?: boolean | import("ignore:preact").ComponentChildren,
65899
66211
  * onErrorDismiss?: (event: Event) => void,
65900
- * loading?: boolean | "adding" | "removing",
66212
+ * loading?: boolean | "adding" | "removing" | "updating",
65901
66213
  * readOnly?: boolean,
65902
66214
  * filtered?: boolean,
65903
66215
  * hidden?: boolean,
@@ -65938,12 +66250,17 @@ const LIST_ITEM_STYLE_CSS_VARS = {
65938
66250
  * like the list's own error. `true` shows a generic sentence. When
65939
66251
  * `onErrorDismiss` is given, a dismiss button is drawn next to the message
65940
66252
  * and calls it.
65941
- * @param {boolean|"adding"|"removing"} [props.loading]
66253
+ * @param {boolean|"adding"|"removing"|"updating"} [props.loading]
65942
66254
  * The row is waiting on something: it draws a loading outline and, like
65943
66255
  * readOnly, stops taking clicks. Works on any item, not only a selectable
65944
- * one — a list is edited row by row. Pass "adding" or "removing" rather than
65945
- * true to say WHAT it is waiting for, which is what a press on it then
65946
- * answers.
66256
+ * one — a list is edited row by row. Rather than true, say WHAT it is
66257
+ * waiting for, which is what a press on it then answers: "adding" (joining
66258
+ * the list), "removing" (leaving it), "updating" (being saved where it is).
66259
+ *
66260
+ * All three are about the row as a thing the LIST holds, never about the
66261
+ * selection: a selectable row taken while its list sends says so on its own
66262
+ * ("la sélection est en cours d'enregistrement"), and needs no `loading` for
66263
+ * that.
65947
66264
  * @param {boolean} [props.readOnly]
65948
66265
  * The row cannot be acted on: dimmed and click-through-proof, buttons inside
65949
66266
  * it included.