@jsenv/navi 0.29.226 → 0.29.228

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.
@@ -3209,6 +3209,12 @@ const actionWeakMap = new WeakMap();
3209
3209
  *
3210
3210
  * @param {Function} callback
3211
3211
  * @param {object} [rootOptions]
3212
+ * @param {(params: any, action: object) => any} [rootOptions.provisionalValue] -
3213
+ * a value the action holds while a run is in flight and it has none of its
3214
+ * own, so a screen already knowing the answer draws it as a refresh rather
3215
+ * than a first load (data_states.md). Called at the start of every run whose
3216
+ * value is undefined; the answer replaces it as usual, and `undefined` means
3217
+ * "nothing known". A resource `GET` uses it to draw the row its store holds.
3212
3218
  * @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
3213
3219
  * answer stays good once it has landed. Without it an answer lives exactly as
3214
3220
  * long as something references it: the screen that asked goes away, and
@@ -3248,6 +3254,7 @@ const createAction = (callback, rootOptions = {}) => {
3248
3254
  value,
3249
3255
  resultToValue,
3250
3256
  valueToData,
3257
+ provisionalValue,
3251
3258
  dataDefault,
3252
3259
  data = dataDefault,
3253
3260
 
@@ -3706,6 +3713,12 @@ const createAction = (callback, rootOptions = {}) => {
3706
3713
  actionAbortMap.set(action, abort);
3707
3714
 
3708
3715
  batch(() => {
3716
+ if (provisionalValue && valueSignal.peek() === undefined) {
3717
+ const provisional = provisionalValue(params, action);
3718
+ if (provisional !== undefined) {
3719
+ valueSignal.value = provisional;
3720
+ }
3721
+ }
3709
3722
  runningStateSignal.value = RUNNING;
3710
3723
  if (!isPrerun) {
3711
3724
  isPrerunSignal.value = false;
@@ -5249,6 +5262,9 @@ const CONTROL_PROP_SET = new Set([
5249
5262
  // The wait this control's action creates is its own: it renders busy and
5250
5263
  // refuses a second press, and nothing above it is told (see BUSY_CONSTRAINT).
5251
5264
  "actionStandalone",
5265
+ // The wait this control's action creates may be called off by the person
5266
+ // waiting: closing the popup it holds gives up on the run (see popup_busy.js).
5267
+ "actionAbortable",
5252
5268
 
5253
5269
  "charGuard",
5254
5270
  "maxLengthGuard",
@@ -8013,11 +8029,10 @@ const BUSY_CONSTRAINT = {
8013
8029
  // Unless it says the wait is its own (`actionStandalone`): then the refusal
8014
8030
  // stays on the element and every ancestor reads it as free — the group above
8015
8031
  // (see getInteractionBlockingControls) and the popup around it (see
8016
- // findBusyElementInside in dialog.jsx and popover.jsx, which both filter on
8017
- // `ignoredByParents`).
8032
+ // popup_busy.js, which filters on `ignoredByParents`).
8018
8033
  check: (field, { intent } = {}) => {
8019
- const isBusy = isControlBusy(field);
8020
- if (!isBusy) {
8034
+ const busySource = findBusySource(field);
8035
+ if (!busySource) {
8021
8036
  return null;
8022
8037
  }
8023
8038
 
@@ -8038,11 +8053,31 @@ const BUSY_CONSTRAINT = {
8038
8053
  message,
8039
8054
  status: "info",
8040
8055
  ignoredByParents: Boolean(field.actionStandalone),
8056
+ // Read off the control the run belongs to, not off this one: a field or a
8057
+ // submit button inherits its form's wait, so it inherits with it whether
8058
+ // the person waiting is allowed to call that wait off (see
8059
+ // abortControlRun, and `actionAbortable` in control_hooks.jsx).
8060
+ abortable: Boolean(busySource.action && busySource.field.actionAbortable),
8041
8061
  };
8042
8062
  },
8043
8063
  };
8044
8064
  CONSTRAINT_ATTRIBUTE_SET.add("data-busy");
8045
8065
 
8066
+ /**
8067
+ * Give up on the run that makes `field` busy — the person waiting deciding the
8068
+ * answer is not coming. The work is not undone: aborting frees the client and
8069
+ * nothing more, the server may already have done it (see docs/actions.md,
8070
+ * "Aborting saves resources, it does not undo"). What ends is the wait, and
8071
+ * with it every refusal held by it.
8072
+ */
8073
+ const abortControlRun = (field, reason) => {
8074
+ const busySource = findBusySource(field);
8075
+ if (!busySource || !busySource.action) {
8076
+ return false;
8077
+ }
8078
+ return busySource.action.abort(reason);
8079
+ };
8080
+
8046
8081
  // Asked source by source rather than off the rendered `aria-busy`, which
8047
8082
  // conflates them and is a frame behind: that attribute is written during
8048
8083
  // render, so it still says "true" for the whole tick in which an action
@@ -8054,9 +8089,15 @@ CONSTRAINT_ATTRIBUTE_SET.add("data-busy");
8054
8089
  // control busy only because the group above is waiting has no state of its own
8055
8090
  // to read — the group's answer IS its answer, so it asks upward and inherits
8056
8091
  // the same live reading.
8057
- const isControlBusy = (field) => {
8092
+ //
8093
+ // What comes back is the control the wait BELONGS to, and the running action
8094
+ // when there is one, because two questions are asked of it: whether this
8095
+ // control is busy at all, and what giving up on that wait would mean.
8096
+ const findBusySource = (field) => {
8058
8097
  if (field.loadingFromOwnProp) {
8059
- return true;
8098
+ // A `loading` prop is a wait the app draws itself; there is no run behind
8099
+ // it to call off.
8100
+ return { field, action: null };
8060
8101
  }
8061
8102
  const { boundAction } = field;
8062
8103
  // An optimistic control stays interactive while its bound action runs:
@@ -8074,14 +8115,14 @@ const isControlBusy = (field) => {
8074
8115
  // cannot move mid-run, this very gate blocks it.
8075
8116
  const liveAction = boundAction.getCurrentAction?.() ?? boundAction;
8076
8117
  if (liveAction.runningStateSignal.value === RUNNING) {
8077
- return true;
8118
+ return { field, action: liveAction };
8078
8119
  }
8079
8120
  }
8080
8121
  if (field.loadingFromAbove) {
8081
8122
  const parent = field.parentUIStateController;
8082
- return parent ? isControlBusy(parent) : false;
8123
+ return parent ? findBusySource(parent) : null;
8083
8124
  }
8084
- return false;
8125
+ return null;
8085
8126
  };
8086
8127
 
8087
8128
  const DISABLED_CONSTRAINT = {
@@ -15101,17 +15142,15 @@ const resource = (
15101
15142
  return item;
15102
15143
  },
15103
15144
  });
15104
- const createRestActionForRoot = createRestActionFactoryForRoot(name, {
15105
- idKey,
15106
- store,
15107
- declarationSite,
15108
- });
15109
15145
  // The row a GET designates, when the store already holds it — by its params:
15110
15146
  // the value under idKey may be the id or any unique key (a route opening a
15111
15147
  // user by id or by slug names both `id`), and a unique key may be given under
15112
- // its own name; or, when the params name no row (a GET without params, or
15113
- // whose params carry no key), the row the action last completed with.
15114
- // Answers a GET under a network policy (see applyNetworkPolicy).
15148
+ // its own name. findItemByParams is what a GET draws while its request is in
15149
+ // flight (the action's provisionalValue). findItemInStore adds, when the
15150
+ // params name no row (a GET without params, or whose params carry no key),
15151
+ // the row the action last completed with: that fallback answers a GET under
15152
+ // a network policy (see applyNetworkPolicy) and only there — a GET of a row
15153
+ // the store lacks must draw its skeleton, never the row read just before.
15115
15154
  const selectByAnyKey = (value) => {
15116
15155
  const item = store.select(value);
15117
15156
  if (item) {
@@ -15163,6 +15202,12 @@ const resource = (
15163
15202
  return store.select(lastItemId) || null;
15164
15203
  });
15165
15204
  };
15205
+ const createRestActionForRoot = createRestActionFactoryForRoot(name, {
15206
+ idKey,
15207
+ store,
15208
+ findItemByParams,
15209
+ declarationSite,
15210
+ });
15166
15211
  return createResource(name, {
15167
15212
  idKey,
15168
15213
  uniqueKeys,
@@ -16154,6 +16199,7 @@ const createRestActionFactoryForRoot = (
16154
16199
  {
16155
16200
  idKey,
16156
16201
  store, // see array_signal_store.js
16202
+ findItemByParams,
16157
16203
  declarationSite,
16158
16204
  },
16159
16205
  ) => {
@@ -16225,6 +16271,16 @@ const createRestActionFactoryForRoot = (
16225
16271
  return applyResultToValue(result);
16226
16272
  },
16227
16273
  valueToData: (itemId) => store.select(itemId),
16274
+ // While the request is out, the row the store already holds for these
16275
+ // params is drawn: the action starts on the "refresh over a known
16276
+ // answer" line of data_states.md rather than the first-load one.
16277
+ provisionalValue:
16278
+ verb === "GET"
16279
+ ? (params) => {
16280
+ const item = untracked(() => findItemByParams(params));
16281
+ return item ? item[idKey] : undefined;
16282
+ }
16283
+ : undefined,
16228
16284
  completeSideEffect: onActionComplete,
16229
16285
  });
16230
16286
  };
@@ -23944,50 +24000,30 @@ const parsePattern = (
23944
24000
  };
23945
24001
 
23946
24002
  /**
23947
- * Check if a literal segment can be treated as optional based on pattern hierarchy
24003
+ * A literal segment the url stops short of is optional when an ancestor
24004
+ * pattern has a path param AT THAT SAME POSITION whose default is this word:
24005
+ * "/admin/settings/:tab" is the page "/admin" opens on when "/admin/:section"
24006
+ * defaults to "settings". Only the connection occupying the segment's index
24007
+ * may excuse it — a query param, or a param sitting elsewhere, says nothing
24008
+ * about this position, so a state defaulting to "games" must not turn
24009
+ * "/places/:slug/games" into a match for "/places/:slug".
23948
24010
  */
23949
24011
  const checkIfLiteralCanBeOptionalWithPatternObj = (
23950
- literalValue,
24012
+ literalSegment,
23951
24013
  patternObj,
23952
24014
  ) => {
23953
- if (!patternObj) {
23954
- return false; // No pattern object available, cannot determine optionality
23955
- }
23956
-
23957
- // Check current pattern's connections
23958
- for (const connection of patternObj.connections) {
23959
- if (connection.isDefaultValue(literalValue)) {
23960
- return true;
23961
- }
23962
- }
23963
-
23964
- // Check parent pattern's connections
23965
- let currentParent = patternObj.parent;
23966
- while (currentParent) {
23967
- for (const connection of currentParent.connections) {
23968
- if (connection.isDefaultValue(literalValue)) {
24015
+ let ancestor = patternObj ? patternObj.parent : null;
24016
+ while (ancestor) {
24017
+ const ancestorSegment = ancestor.pattern.segments[literalSegment.index];
24018
+ if (ancestorSegment && ancestorSegment.type === "param") {
24019
+ const connection = ancestor.pathConnectionMap.get(ancestorSegment.name);
24020
+ if (connection && connection.isDefaultValue(literalSegment.value)) {
23969
24021
  return true;
23970
24022
  }
23971
24023
  }
23972
- currentParent = currentParent.parent;
24024
+ ancestor = ancestor.parent;
23973
24025
  }
23974
-
23975
- // Check children pattern's connections
23976
- const checkChildrenRecursively = (pattern) => {
23977
- for (const child of pattern.children || []) {
23978
- for (const connection of child.connections) {
23979
- if (connection.isDefaultValue(literalValue)) {
23980
- return true;
23981
- }
23982
- }
23983
- if (checkChildrenRecursively(child)) {
23984
- return true;
23985
- }
23986
- }
23987
- return false;
23988
- };
23989
-
23990
- return checkChildrenRecursively(patternObj);
24026
+ return false;
23991
24027
  };
23992
24028
 
23993
24029
  /**
@@ -24132,9 +24168,8 @@ const matchUrl = (
24132
24168
  // Check if URL has this segment
24133
24169
  if (urlSegmentIndex >= urlSegments.length) {
24134
24170
  // URL is too short for this literal segment
24135
- // Check if this literal segment can be treated as optional based on pattern hierarchy
24136
24171
  const canBeOptional = checkIfLiteralCanBeOptionalWithPatternObj(
24137
- patternSeg.value,
24172
+ patternSeg,
24138
24173
  patternObj,
24139
24174
  );
24140
24175
  if (canBeOptional) {
@@ -37628,7 +37663,8 @@ const useInteractiveProps = (props, {
37628
37663
  readOnly,
37629
37664
  loading,
37630
37665
  optimistic,
37631
- actionStandalone
37666
+ actionStandalone,
37667
+ actionAbortable
37632
37668
  } = props;
37633
37669
 
37634
37670
  // `whenSelfInteractionsBlocked="ignore"`: an affordance that writes nothing
@@ -37692,6 +37728,12 @@ const useInteractiveProps = (props, {
37692
37728
  // above it is told, so a form still submits and a popup still closes over a
37693
37729
  // run that was meant to be left going.
37694
37730
  uiStateController.actionStandalone = Boolean(actionStandalone);
37731
+ // Read by BUSY_CONSTRAINT: the wait holds what is around it as always, and
37732
+ // the person waiting may end it — closing the popup this control holds
37733
+ // calls the run off instead of being refused (see popup_busy.js). Read off
37734
+ // the control the run belongs to, so a field or a submit inheriting a
37735
+ // form's wait inherits this with it.
37736
+ uiStateController.actionAbortable = Boolean(actionAbortable);
37695
37737
  // What the interaction rule last refused is only true while the control is
37696
37738
  // held; the state it was read from moves here (see refreshReport).
37697
37739
  useLayoutEffect(() => {
@@ -48352,6 +48394,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
48352
48394
  * whenSelfInteractionsBlocked?: "hide" | "refuse" | "ignore",
48353
48395
  * replace?: boolean,
48354
48396
  * actionStandalone?: boolean,
48397
+ * actionAbortable?: boolean,
48355
48398
  * [key: string]: any,
48356
48399
  * }>}
48357
48400
  * @param {boolean} [replace] Go where the press leads — an `href`, a
@@ -48395,6 +48438,14 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
48395
48438
  * running, which the app watches from somewhere else; never for one holding
48396
48439
  * an answer the screen is the only place to read (see
48397
48440
  * docs/interactions.md#the-fourth-question-whose-wait-is-it).
48441
+ * @param {boolean} [actionAbortable] The person waiting may give up on this
48442
+ * button's action: closing the popup the run holds calls it off and goes
48443
+ * through, instead of being refused. For a run whose answer may never come —
48444
+ * a request over a network that stops answering — where a popup with no way
48445
+ * out is worse than an answer lost. Aborting frees the client and nothing
48446
+ * more: the work may already have happened on the other side (see
48447
+ * docs/actions.md#aborting-saves-resources-it-does-not-undo), so say it only
48448
+ * where the screen can be re-opened on what is actually there.
48398
48449
  * @param {string} [contentDisplay] The display of the frame the button draws
48399
48450
  * around its children. It follows the button's own by default — its display
48400
48451
  * and, a display alone saying nothing about direction, the rest of its flow
@@ -57342,6 +57393,66 @@ const getAvailableWidth = (layer, element) => {
57342
57393
  return visualViewportWidthSignal.peek();
57343
57394
  };
57344
57395
 
57396
+ /**
57397
+ * What a popup asks before it closes: is anything inside it mid-action, and may
57398
+ * the person waiting call that wait off.
57399
+ *
57400
+ * A popup carries no state of its own — it is layout (see dialog.jsx's top
57401
+ * comment) — so it walks the controls it contains and asks each one. Asked
57402
+ * rather than read off `aria-busy`, which is a render snapshot: BUSY_CONSTRAINT
57403
+ * answers live.
57404
+ *
57405
+ * `Dialog` and `Popover` both read this way and read it identically, so it
57406
+ * lives here rather than in each of them: the answer decides whether a close
57407
+ * request goes through, and the two must not drift on that.
57408
+ */
57409
+
57410
+
57411
+ /**
57412
+ * The controls keeping `popupEl` open: mid-action, and not saying the wait is
57413
+ * their own. `ignoredByParents` is that saying (`actionStandalone`) — a popup
57414
+ * is one more ancestor such a control does not hold, the same reading a group
57415
+ * makes of it (see control_interaction.js).
57416
+ */
57417
+ const findControlsHoldingPopup = (popupEl) => {
57418
+ const controlsHolding = [];
57419
+ for (const element of popupEl.querySelectorAll("[navi-control-host]")) {
57420
+ const controller = element.__uiStateController__;
57421
+ if (!controller) {
57422
+ continue;
57423
+ }
57424
+ const busyInfo = BUSY_CONSTRAINT.check(controller);
57425
+ if (busyInfo && !busyInfo.ignoredByParents) {
57426
+ controlsHolding.push({
57427
+ element,
57428
+ controller,
57429
+ abortable: busyInfo.abortable,
57430
+ });
57431
+ }
57432
+ }
57433
+ return controlsHolding;
57434
+ };
57435
+
57436
+ /**
57437
+ * Give up on every run holding the popup, so that closing may go through.
57438
+ *
57439
+ * All or nothing: calling off one run while another still keeps the popup shut
57440
+ * would cost an answer and change nothing on screen. Several of these controls
57441
+ * usually share one run — a form and the fields and submit inside it — and the
57442
+ * ones asked after it has been called off simply find nothing to call off.
57443
+ */
57444
+ const giveUpOnControlsHoldingPopup = (controlsHolding, reason) => {
57445
+ for (const controlHolding of controlsHolding) {
57446
+ if (!controlHolding.abortable) {
57447
+ return false;
57448
+ }
57449
+ }
57450
+ for (const controlHolding of controlsHolding) {
57451
+ abortControlRun(controlHolding.controller, reason);
57452
+ }
57453
+ return true;
57454
+ };
57455
+
57345
57456
  /**
57346
57457
  * Entry/exit animation CSS shared by Popover and Dialog.
57347
57458
  *
@@ -58131,7 +58242,7 @@ const PopupClose = ({
58131
58242
  // and what is opened has to be closable. Whether closing is allowed at
58132
58243
  // this instant is the popup's own question, answered by the popup — a
58133
58244
  // dialog holds the close while an action inside it runs (see
58134
- // findBusyElementInside in dialog.jsx).
58245
+ // popup_busy.js).
58135
58246
  ,
58136
58247
  whenSelfInteractionsBlocked: "ignore"
58137
58248
  // The cross is drawn at the control size, which is a few millimetres
@@ -58880,14 +58991,21 @@ const UncontrolledDialog = props => {
58880
58991
  // dialog has no such state of its own to consult (it is layout — see
58881
58992
  // this file's top comment), so it asks what it contains, and lets that
58882
58993
  // control report why the way it would to anyone else.
58883
- const busyElement = findBusyElementInside$1(dialogEl);
58884
- if (busyElement) {
58885
- dispatchRequestInteraction(busyElement, {
58886
- event: requestCloseEvent,
58887
- name: "dialog request close"
58888
- });
58889
- requestCloseEvent.preventDefault();
58994
+ const controlsHolding = findControlsHoldingPopup(dialogEl);
58995
+ if (controlsHolding.length === 0) {
58996
+ return;
58890
58997
  }
58998
+ // Unless every one of those runs was declared givable-up
58999
+ // (`actionAbortable`): the answer is not coming, closing is how the
59000
+ // person waiting says so, and the runs are called off on the way out.
59001
+ if (giveUpOnControlsHoldingPopup(controlsHolding, "the dialog holding the run was closed")) {
59002
+ return;
59003
+ }
59004
+ dispatchRequestInteraction(controlsHolding[0].element, {
59005
+ event: requestCloseEvent,
59006
+ name: "dialog request close"
59007
+ });
59008
+ requestCloseEvent.preventDefault();
58891
59009
  },
58892
59010
  onClose: closeEvent => {
58893
59011
  props.onClose?.(closeEvent);
@@ -59007,29 +59125,6 @@ const DOCKED = {
59007
59125
  // no header and nothing marked is not pushed down at all; it is closed by its
59008
59126
  // own controls, by the backdrop and by Escape.
59009
59127
  const DOCKED_SWIPE_GRIP = "[data-header],[data-swipe-grip]";
59010
-
59011
- // The first control inside `dialogEl` that is mid-action, if any. Walks the
59012
- // controls rather than reading an attribute off the dialog: a dialog carries no
59013
- // state of its own (see this file's top comment), and `aria-busy` on the
59014
- // controls is a render snapshot — BUSY_CONSTRAINT reads the live answer.
59015
- // Same as Popover's own; kept in both rather than shared, since each file reads
59016
- // on its own — what changes here changes there too.
59017
- const findBusyElementInside$1 = dialogEl => {
59018
- for (const element of dialogEl.querySelectorAll("[navi-control-host]")) {
59019
- const controller = element.__uiStateController__;
59020
- if (!controller) {
59021
- continue;
59022
- }
59023
- const busyInfo = BUSY_CONSTRAINT.check(controller);
59024
- // `ignoredByParents`: the control says the wait is its own
59025
- // (`actionStandalone`), so a popup is one more ancestor it does not hold —
59026
- // the same reading a group makes of it (see control_interaction.js).
59027
- if (busyInfo && !busyInfo.ignoredByParents) {
59028
- return element;
59029
- }
59030
- }
59031
- return null;
59032
- };
59033
59128
  const useDialogProps = props => {
59034
59129
  const backdropProps = {};
59035
59130
  const contentProps = {};
@@ -60491,14 +60586,21 @@ const UncontrolledPopover = props => {
60491
60586
  debugPopup(openEvent, `popover opened`);
60492
60587
  return {
60493
60588
  onRequestClose: requestCloseEvent => {
60494
- const busyElement = findBusyElementInside(popoverEl);
60495
- if (busyElement) {
60496
- dispatchRequestInteraction(busyElement, {
60497
- event: requestCloseEvent,
60498
- name: "popover request close"
60499
- });
60500
- requestCloseEvent.preventDefault();
60589
+ const controlsHolding = findControlsHoldingPopup(popoverEl);
60590
+ if (controlsHolding.length === 0) {
60591
+ return;
60501
60592
  }
60593
+ // Unless every one of those runs was declared givable-up
60594
+ // (`actionAbortable`): the answer is not coming, closing is how the
60595
+ // person waiting says so, and the runs are called off on the way out.
60596
+ if (giveUpOnControlsHoldingPopup(controlsHolding, "the popover holding the run was closed")) {
60597
+ return;
60598
+ }
60599
+ dispatchRequestInteraction(controlsHolding[0].element, {
60600
+ event: requestCloseEvent,
60601
+ name: "popover request close"
60602
+ });
60603
+ requestCloseEvent.preventDefault();
60502
60604
  },
60503
60605
  onClose: closeEvent => {
60504
60606
  props.onClose?.(closeEvent);
@@ -60607,28 +60709,6 @@ const PopoverCustom = props => {
60607
60709
  * contentProps]` — two plain prop objects ready to spread onto a
60608
60710
  * backdrop/content element each.
60609
60711
  */
60610
- // The first control inside `popupEl` that is mid-action, if any. Walks the
60611
- // controls rather than reading an attribute off the popup: a popup carries no
60612
- // state of its own (see this file's top comment), and `aria-busy` on the
60613
- // controls is a render snapshot — BUSY_CONSTRAINT reads the live answer.
60614
- // Same as Dialog's own; kept in both rather than shared, since each file reads
60615
- // on its own — what changes here changes there too.
60616
- const findBusyElementInside = popupEl => {
60617
- for (const element of popupEl.querySelectorAll("[navi-control-host]")) {
60618
- const controller = element.__uiStateController__;
60619
- if (!controller) {
60620
- continue;
60621
- }
60622
- const busyInfo = BUSY_CONSTRAINT.check(controller);
60623
- // `ignoredByParents`: the control says the wait is its own
60624
- // (`actionStandalone`), so a popup is one more ancestor it does not hold —
60625
- // the same reading a group makes of it (see control_interaction.js).
60626
- if (busyInfo && !busyInfo.ignoredByParents) {
60627
- return element;
60628
- }
60629
- }
60630
- return null;
60631
- };
60632
60712
  const usePopoverProps = props => {
60633
60713
  const backdropProps = {};
60634
60714
  const contentProps = {};