@jsenv/navi 0.29.226 → 0.29.227

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.
@@ -5249,6 +5249,9 @@ const CONTROL_PROP_SET = new Set([
5249
5249
  // The wait this control's action creates is its own: it renders busy and
5250
5250
  // refuses a second press, and nothing above it is told (see BUSY_CONSTRAINT).
5251
5251
  "actionStandalone",
5252
+ // The wait this control's action creates may be called off by the person
5253
+ // waiting: closing the popup it holds gives up on the run (see popup_busy.js).
5254
+ "actionAbortable",
5252
5255
 
5253
5256
  "charGuard",
5254
5257
  "maxLengthGuard",
@@ -8013,11 +8016,10 @@ const BUSY_CONSTRAINT = {
8013
8016
  // Unless it says the wait is its own (`actionStandalone`): then the refusal
8014
8017
  // stays on the element and every ancestor reads it as free — the group above
8015
8018
  // (see getInteractionBlockingControls) and the popup around it (see
8016
- // findBusyElementInside in dialog.jsx and popover.jsx, which both filter on
8017
- // `ignoredByParents`).
8019
+ // popup_busy.js, which filters on `ignoredByParents`).
8018
8020
  check: (field, { intent } = {}) => {
8019
- const isBusy = isControlBusy(field);
8020
- if (!isBusy) {
8021
+ const busySource = findBusySource(field);
8022
+ if (!busySource) {
8021
8023
  return null;
8022
8024
  }
8023
8025
 
@@ -8038,11 +8040,31 @@ const BUSY_CONSTRAINT = {
8038
8040
  message,
8039
8041
  status: "info",
8040
8042
  ignoredByParents: Boolean(field.actionStandalone),
8043
+ // Read off the control the run belongs to, not off this one: a field or a
8044
+ // submit button inherits its form's wait, so it inherits with it whether
8045
+ // the person waiting is allowed to call that wait off (see
8046
+ // abortControlRun, and `actionAbortable` in control_hooks.jsx).
8047
+ abortable: Boolean(busySource.action && busySource.field.actionAbortable),
8041
8048
  };
8042
8049
  },
8043
8050
  };
8044
8051
  CONSTRAINT_ATTRIBUTE_SET.add("data-busy");
8045
8052
 
8053
+ /**
8054
+ * Give up on the run that makes `field` busy — the person waiting deciding the
8055
+ * answer is not coming. The work is not undone: aborting frees the client and
8056
+ * nothing more, the server may already have done it (see docs/actions.md,
8057
+ * "Aborting saves resources, it does not undo"). What ends is the wait, and
8058
+ * with it every refusal held by it.
8059
+ */
8060
+ const abortControlRun = (field, reason) => {
8061
+ const busySource = findBusySource(field);
8062
+ if (!busySource || !busySource.action) {
8063
+ return false;
8064
+ }
8065
+ return busySource.action.abort(reason);
8066
+ };
8067
+
8046
8068
  // Asked source by source rather than off the rendered `aria-busy`, which
8047
8069
  // conflates them and is a frame behind: that attribute is written during
8048
8070
  // render, so it still says "true" for the whole tick in which an action
@@ -8054,9 +8076,15 @@ CONSTRAINT_ATTRIBUTE_SET.add("data-busy");
8054
8076
  // control busy only because the group above is waiting has no state of its own
8055
8077
  // to read — the group's answer IS its answer, so it asks upward and inherits
8056
8078
  // the same live reading.
8057
- const isControlBusy = (field) => {
8079
+ //
8080
+ // What comes back is the control the wait BELONGS to, and the running action
8081
+ // when there is one, because two questions are asked of it: whether this
8082
+ // control is busy at all, and what giving up on that wait would mean.
8083
+ const findBusySource = (field) => {
8058
8084
  if (field.loadingFromOwnProp) {
8059
- return true;
8085
+ // A `loading` prop is a wait the app draws itself; there is no run behind
8086
+ // it to call off.
8087
+ return { field, action: null };
8060
8088
  }
8061
8089
  const { boundAction } = field;
8062
8090
  // An optimistic control stays interactive while its bound action runs:
@@ -8074,14 +8102,14 @@ const isControlBusy = (field) => {
8074
8102
  // cannot move mid-run, this very gate blocks it.
8075
8103
  const liveAction = boundAction.getCurrentAction?.() ?? boundAction;
8076
8104
  if (liveAction.runningStateSignal.value === RUNNING) {
8077
- return true;
8105
+ return { field, action: liveAction };
8078
8106
  }
8079
8107
  }
8080
8108
  if (field.loadingFromAbove) {
8081
8109
  const parent = field.parentUIStateController;
8082
- return parent ? isControlBusy(parent) : false;
8110
+ return parent ? findBusySource(parent) : null;
8083
8111
  }
8084
- return false;
8112
+ return null;
8085
8113
  };
8086
8114
 
8087
8115
  const DISABLED_CONSTRAINT = {
@@ -23944,50 +23972,30 @@ const parsePattern = (
23944
23972
  };
23945
23973
 
23946
23974
  /**
23947
- * Check if a literal segment can be treated as optional based on pattern hierarchy
23975
+ * A literal segment the url stops short of is optional when an ancestor
23976
+ * pattern has a path param AT THAT SAME POSITION whose default is this word:
23977
+ * "/admin/settings/:tab" is the page "/admin" opens on when "/admin/:section"
23978
+ * defaults to "settings". Only the connection occupying the segment's index
23979
+ * may excuse it — a query param, or a param sitting elsewhere, says nothing
23980
+ * about this position, so a state defaulting to "games" must not turn
23981
+ * "/places/:slug/games" into a match for "/places/:slug".
23948
23982
  */
23949
23983
  const checkIfLiteralCanBeOptionalWithPatternObj = (
23950
- literalValue,
23984
+ literalSegment,
23951
23985
  patternObj,
23952
23986
  ) => {
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)) {
23987
+ let ancestor = patternObj ? patternObj.parent : null;
23988
+ while (ancestor) {
23989
+ const ancestorSegment = ancestor.pattern.segments[literalSegment.index];
23990
+ if (ancestorSegment && ancestorSegment.type === "param") {
23991
+ const connection = ancestor.pathConnectionMap.get(ancestorSegment.name);
23992
+ if (connection && connection.isDefaultValue(literalSegment.value)) {
23969
23993
  return true;
23970
23994
  }
23971
23995
  }
23972
- currentParent = currentParent.parent;
23996
+ ancestor = ancestor.parent;
23973
23997
  }
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);
23998
+ return false;
23991
23999
  };
23992
24000
 
23993
24001
  /**
@@ -24132,9 +24140,8 @@ const matchUrl = (
24132
24140
  // Check if URL has this segment
24133
24141
  if (urlSegmentIndex >= urlSegments.length) {
24134
24142
  // URL is too short for this literal segment
24135
- // Check if this literal segment can be treated as optional based on pattern hierarchy
24136
24143
  const canBeOptional = checkIfLiteralCanBeOptionalWithPatternObj(
24137
- patternSeg.value,
24144
+ patternSeg,
24138
24145
  patternObj,
24139
24146
  );
24140
24147
  if (canBeOptional) {
@@ -37628,7 +37635,8 @@ const useInteractiveProps = (props, {
37628
37635
  readOnly,
37629
37636
  loading,
37630
37637
  optimistic,
37631
- actionStandalone
37638
+ actionStandalone,
37639
+ actionAbortable
37632
37640
  } = props;
37633
37641
 
37634
37642
  // `whenSelfInteractionsBlocked="ignore"`: an affordance that writes nothing
@@ -37692,6 +37700,12 @@ const useInteractiveProps = (props, {
37692
37700
  // above it is told, so a form still submits and a popup still closes over a
37693
37701
  // run that was meant to be left going.
37694
37702
  uiStateController.actionStandalone = Boolean(actionStandalone);
37703
+ // Read by BUSY_CONSTRAINT: the wait holds what is around it as always, and
37704
+ // the person waiting may end it — closing the popup this control holds
37705
+ // calls the run off instead of being refused (see popup_busy.js). Read off
37706
+ // the control the run belongs to, so a field or a submit inheriting a
37707
+ // form's wait inherits this with it.
37708
+ uiStateController.actionAbortable = Boolean(actionAbortable);
37695
37709
  // What the interaction rule last refused is only true while the control is
37696
37710
  // held; the state it was read from moves here (see refreshReport).
37697
37711
  useLayoutEffect(() => {
@@ -48352,6 +48366,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
48352
48366
  * whenSelfInteractionsBlocked?: "hide" | "refuse" | "ignore",
48353
48367
  * replace?: boolean,
48354
48368
  * actionStandalone?: boolean,
48369
+ * actionAbortable?: boolean,
48355
48370
  * [key: string]: any,
48356
48371
  * }>}
48357
48372
  * @param {boolean} [replace] Go where the press leads — an `href`, a
@@ -48395,6 +48410,14 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
48395
48410
  * running, which the app watches from somewhere else; never for one holding
48396
48411
  * an answer the screen is the only place to read (see
48397
48412
  * docs/interactions.md#the-fourth-question-whose-wait-is-it).
48413
+ * @param {boolean} [actionAbortable] The person waiting may give up on this
48414
+ * button's action: closing the popup the run holds calls it off and goes
48415
+ * through, instead of being refused. For a run whose answer may never come —
48416
+ * a request over a network that stops answering — where a popup with no way
48417
+ * out is worse than an answer lost. Aborting frees the client and nothing
48418
+ * more: the work may already have happened on the other side (see
48419
+ * docs/actions.md#aborting-saves-resources-it-does-not-undo), so say it only
48420
+ * where the screen can be re-opened on what is actually there.
48398
48421
  * @param {string} [contentDisplay] The display of the frame the button draws
48399
48422
  * around its children. It follows the button's own by default — its display
48400
48423
  * and, a display alone saying nothing about direction, the rest of its flow
@@ -57342,6 +57365,66 @@ const getAvailableWidth = (layer, element) => {
57342
57365
  return visualViewportWidthSignal.peek();
57343
57366
  };
57344
57367
 
57368
+ /**
57369
+ * What a popup asks before it closes: is anything inside it mid-action, and may
57370
+ * the person waiting call that wait off.
57371
+ *
57372
+ * A popup carries no state of its own — it is layout (see dialog.jsx's top
57373
+ * comment) — so it walks the controls it contains and asks each one. Asked
57374
+ * rather than read off `aria-busy`, which is a render snapshot: BUSY_CONSTRAINT
57375
+ * answers live.
57376
+ *
57377
+ * `Dialog` and `Popover` both read this way and read it identically, so it
57378
+ * lives here rather than in each of them: the answer decides whether a close
57379
+ * request goes through, and the two must not drift on that.
57380
+ */
57381
+
57382
+
57383
+ /**
57384
+ * The controls keeping `popupEl` open: mid-action, and not saying the wait is
57385
+ * their own. `ignoredByParents` is that saying (`actionStandalone`) — a popup
57386
+ * is one more ancestor such a control does not hold, the same reading a group
57387
+ * makes of it (see control_interaction.js).
57388
+ */
57389
+ const findControlsHoldingPopup = (popupEl) => {
57390
+ const controlsHolding = [];
57391
+ for (const element of popupEl.querySelectorAll("[navi-control-host]")) {
57392
+ const controller = element.__uiStateController__;
57393
+ if (!controller) {
57394
+ continue;
57395
+ }
57396
+ const busyInfo = BUSY_CONSTRAINT.check(controller);
57397
+ if (busyInfo && !busyInfo.ignoredByParents) {
57398
+ controlsHolding.push({
57399
+ element,
57400
+ controller,
57401
+ abortable: busyInfo.abortable,
57402
+ });
57403
+ }
57404
+ }
57405
+ return controlsHolding;
57406
+ };
57407
+
57408
+ /**
57409
+ * Give up on every run holding the popup, so that closing may go through.
57410
+ *
57411
+ * All or nothing: calling off one run while another still keeps the popup shut
57412
+ * would cost an answer and change nothing on screen. Several of these controls
57413
+ * usually share one run — a form and the fields and submit inside it — and the
57414
+ * ones asked after it has been called off simply find nothing to call off.
57415
+ */
57416
+ const giveUpOnControlsHoldingPopup = (controlsHolding, reason) => {
57417
+ for (const controlHolding of controlsHolding) {
57418
+ if (!controlHolding.abortable) {
57419
+ return false;
57420
+ }
57421
+ }
57422
+ for (const controlHolding of controlsHolding) {
57423
+ abortControlRun(controlHolding.controller, reason);
57424
+ }
57425
+ return true;
57426
+ };
57427
+
57345
57428
  /**
57346
57429
  * Entry/exit animation CSS shared by Popover and Dialog.
57347
57430
  *
@@ -58131,7 +58214,7 @@ const PopupClose = ({
58131
58214
  // and what is opened has to be closable. Whether closing is allowed at
58132
58215
  // this instant is the popup's own question, answered by the popup — a
58133
58216
  // dialog holds the close while an action inside it runs (see
58134
- // findBusyElementInside in dialog.jsx).
58217
+ // popup_busy.js).
58135
58218
  ,
58136
58219
  whenSelfInteractionsBlocked: "ignore"
58137
58220
  // The cross is drawn at the control size, which is a few millimetres
@@ -58880,14 +58963,21 @@ const UncontrolledDialog = props => {
58880
58963
  // dialog has no such state of its own to consult (it is layout — see
58881
58964
  // this file's top comment), so it asks what it contains, and lets that
58882
58965
  // 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();
58966
+ const controlsHolding = findControlsHoldingPopup(dialogEl);
58967
+ if (controlsHolding.length === 0) {
58968
+ return;
58969
+ }
58970
+ // Unless every one of those runs was declared givable-up
58971
+ // (`actionAbortable`): the answer is not coming, closing is how the
58972
+ // person waiting says so, and the runs are called off on the way out.
58973
+ if (giveUpOnControlsHoldingPopup(controlsHolding, "the dialog holding the run was closed")) {
58974
+ return;
58890
58975
  }
58976
+ dispatchRequestInteraction(controlsHolding[0].element, {
58977
+ event: requestCloseEvent,
58978
+ name: "dialog request close"
58979
+ });
58980
+ requestCloseEvent.preventDefault();
58891
58981
  },
58892
58982
  onClose: closeEvent => {
58893
58983
  props.onClose?.(closeEvent);
@@ -59007,29 +59097,6 @@ const DOCKED = {
59007
59097
  // no header and nothing marked is not pushed down at all; it is closed by its
59008
59098
  // own controls, by the backdrop and by Escape.
59009
59099
  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
59100
  const useDialogProps = props => {
59034
59101
  const backdropProps = {};
59035
59102
  const contentProps = {};
@@ -60491,14 +60558,21 @@ const UncontrolledPopover = props => {
60491
60558
  debugPopup(openEvent, `popover opened`);
60492
60559
  return {
60493
60560
  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();
60561
+ const controlsHolding = findControlsHoldingPopup(popoverEl);
60562
+ if (controlsHolding.length === 0) {
60563
+ return;
60501
60564
  }
60565
+ // Unless every one of those runs was declared givable-up
60566
+ // (`actionAbortable`): the answer is not coming, closing is how the
60567
+ // person waiting says so, and the runs are called off on the way out.
60568
+ if (giveUpOnControlsHoldingPopup(controlsHolding, "the popover holding the run was closed")) {
60569
+ return;
60570
+ }
60571
+ dispatchRequestInteraction(controlsHolding[0].element, {
60572
+ event: requestCloseEvent,
60573
+ name: "popover request close"
60574
+ });
60575
+ requestCloseEvent.preventDefault();
60502
60576
  },
60503
60577
  onClose: closeEvent => {
60504
60578
  props.onClose?.(closeEvent);
@@ -60607,28 +60681,6 @@ const PopoverCustom = props => {
60607
60681
  * contentProps]` — two plain prop objects ready to spread onto a
60608
60682
  * backdrop/content element each.
60609
60683
  */
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
60684
  const usePopoverProps = props => {
60633
60685
  const backdropProps = {};
60634
60686
  const contentProps = {};