@jsenv/navi 0.29.414 → 0.29.415

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.
@@ -14110,6 +14110,31 @@ const moveFocusTo = (
14110
14110
  });
14111
14111
  };
14112
14112
 
14113
+ /**
14114
+ * Gives the focus back to `target` once the commit a close is part of is over:
14115
+ * for a close caused by an unmount (UNMOUNT_EVENT_TYPE in
14116
+ * layout/open_controller.js), which runs from a cleanup while the tree leaving
14117
+ * with it is still in the document. Only its end tells a target leaving with
14118
+ * it — disconnected by then, and skipped — from one staying, and a focused
14119
+ * element that leaves makes the removal of its whole subtree restyle it.
14120
+ *
14121
+ * And only into a void: the close took the focus with it, so something holding
14122
+ * the focus by then — a page arriving that focused a field of its own — took it
14123
+ * after, and keeps it.
14124
+ */
14125
+ const giveFocusBackAfterCommit = (target, giveFocus) => {
14126
+ queueMicrotask(() => {
14127
+ if (!target || !target.isConnected) {
14128
+ return;
14129
+ }
14130
+ const focusedNow = document.activeElement;
14131
+ if (focusedNow && focusedNow !== document.body) {
14132
+ return;
14133
+ }
14134
+ giveFocus();
14135
+ });
14136
+ };
14137
+
14113
14138
  const focusTransferTarget = (target, focusVisible) => {
14114
14139
  moveFocusTo(target, { focusVisible });
14115
14140
  if (target.hasAttribute("navi-autofocus-select")) {
@@ -14664,6 +14689,12 @@ const createOpenController = (
14664
14689
  closeEvent,
14665
14690
  `closed by focusout -> let focus go away`,
14666
14691
  );
14692
+ } else if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
14693
+ // Left the tree: what held the focus before the opening may be
14694
+ // leaving with it (see giveFocusBackAfterCommit).
14695
+ giveFocusBackAfterCommit(focusTransfer.focusedElement, () => {
14696
+ focusTransfer.restoreFocus(closeEvent);
14697
+ });
14667
14698
  } else {
14668
14699
  // Only the mousedown, deliberately: a popup with no backdrop is
14669
14700
  // closed by a `pointerdown` that belongs to the page (see
@@ -17425,14 +17456,8 @@ const createCalloutManager = (
17425
17456
  };
17426
17457
  if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
17427
17458
  // Closed by a popup leaving the tree: the control may be leaving
17428
- // in the same commit, which only its end tells. A leaving control
17429
- // must not get the focus — the removal of a subtree holding it
17430
- // restyles the whole subtree.
17431
- queueMicrotask(() => {
17432
- if (focusTarget.isConnected) {
17433
- giveFocusBack();
17434
- }
17435
- });
17459
+ // with it (see giveFocusBackAfterCommit).
17460
+ giveFocusBackAfterCommit(focusTarget, giveFocusBack);
17436
17461
  } else {
17437
17462
  // Given before the callout leaves the document: removed while it
17438
17463
  // holds the focus, it sends a focusout with nowhere to go up
@@ -31341,7 +31366,11 @@ const useContentKeepingLeavingPages = branch => {
31341
31366
  // mounted with, or it would be mounted again.
31342
31367
  key = shown.key;
31343
31368
  page = shown.page;
31344
- } else if (shown && leavingPagesHold) {
31369
+ } else if (shown && leavingPagesHold && !document.querySelector(":modal")) {
31370
+ // Never while a modal dialog is open: it keeps everything but itself out
31371
+ // of reach — the page arriving could not even take the focus — for as long
31372
+ // as it is open, and one in a page kept while leaving stays open for the
31373
+ // whole movement. The page is then taken down at once, as without one.
31345
31374
  leavingRef.current.push({
31346
31375
  vnode: shown.vnode,
31347
31376
  page: shown.page,
@@ -31388,10 +31417,17 @@ const useContentKeepingLeavingPages = branch => {
31388
31417
  }
31389
31418
  entry.hidden = true;
31390
31419
  entry.page.leavingSignal.value = true;
31420
+ const focusedElement = document.activeElement;
31391
31421
  let node = start.nextSibling;
31392
31422
  while (node && node !== end) {
31393
31423
  if (node.nodeType === Node.ELEMENT_NODE) {
31394
31424
  node.setAttribute(LEAVING_ATTRIBUTE, "");
31425
+ // What the keyboard held in it goes where the page's removal would
31426
+ // have sent it: a hidden element keeps the focus, and would go on
31427
+ // receiving the keys pressed during the movement.
31428
+ if (node.contains(focusedElement)) {
31429
+ focusedElement.blur();
31430
+ }
31395
31431
  }
31396
31432
  node = node.nextSibling;
31397
31433
  }
@@ -43958,8 +43994,12 @@ const useInteractiveProps = (props, {
43958
43994
  }
43959
43995
  // Read by READONLY_CONSTRAINT to say how many runs are already out, and by
43960
43996
  // the action handlers below, which are where a run joins and leaves the
43961
- // count (a labelled block away, hence through the controller).
43962
- uiStateController.parallelGuard = parallelGuard;
43997
+ // count (a labelled block away, hence through the controller). None for a
43998
+ // control without an action of its own, by the same rule as being held
43999
+ // back: what it runs (the noop every such control has, a link's
44000
+ // navigation) is nothing the guard counts, and would hold a slot for its
44001
+ // length.
44002
+ uiStateController.parallelGuard = props.action ? parallelGuard : null;
43963
44003
  if (heldByParallelGuard) {
43964
44004
  controlHostProps["data-readonly-reason"] = "parallel-guard";
43965
44005
  }
@@ -44233,7 +44273,11 @@ const useInteractiveProps = (props, {
44233
44273
  // carried by every navi_action_* event of that run).
44234
44274
  uiStateController.pendingActionEvent = e.detail.event;
44235
44275
  uiStateController.actionInFlight = true;
44236
- uiStateController.parallelGuard?.claim(uiStateController);
44276
+ // Given back to the guard it was counted in, whatever the control
44277
+ // holds by then: one that lost its action mid-run has no guard left
44278
+ // to name, and the slot would stay taken.
44279
+ const countedIn = uiStateController.parallelGuard;
44280
+ countedIn?.claim(uiStateController);
44237
44281
  if (optimistic) {
44238
44282
  syncOptimisticAttribute(true);
44239
44283
  }
@@ -44251,7 +44295,7 @@ const useInteractiveProps = (props, {
44251
44295
  e.detail.addSideEffect((outcome, outcomeEvent) => {
44252
44296
  uiStateController.actionInFlight = false;
44253
44297
  uiStateController.runningActionSignal.value = null;
44254
- uiStateController.parallelGuard?.release(uiStateController);
44298
+ countedIn?.release(uiStateController);
44255
44299
  // The outcome callbacks are told from here, the run's side effect,
44256
44300
  // and not from the onnavi_action_* handlers below: the run can
44257
44301
  // outlive this control (its own answer, written to a store the
@@ -47783,6 +47827,90 @@ const BinderItemContext = createContext(null);
47783
47827
  */
47784
47828
  const NavContext = createContext(null);
47785
47829
 
47830
+ /**
47831
+ * Whether the page one is on is `route` with `params` — what a link or a button
47832
+ * pointing at a route shows as current.
47833
+ *
47834
+ * Read through a computed of that one answer, so the component reading it
47835
+ * re-renders when the answer flips, and not whenever the route's status moves:
47836
+ * a page arriving on the route with other params — one profile opening, under a
47837
+ * list of links to a hundred other profiles — starts the route matching and
47838
+ * changes its params, and leaves every one of those links exactly as it was.
47839
+ *
47840
+ * `except`: routes whose matching vetoes the answer — a route named there is
47841
+ * somewhere else, whatever the rest of the reading says. `also`: routes whose
47842
+ * matching grants it. Both take a route or a list of routes.
47843
+ */
47844
+ const useRouteCurrent = (route, params, { except, also } = {}) => {
47845
+ const exceptRoutes = toRouteList(except);
47846
+ const alsoRoutes = toRouteList(also);
47847
+ const paramsKey = getParamsCacheKey(params);
47848
+ const exceptKey = routeListKey(exceptRoutes);
47849
+ const alsoKey = routeListKey(alsoRoutes);
47850
+ const currentSignal = useMemo(() => {
47851
+ if (paramsKey === null) {
47852
+ return null;
47853
+ }
47854
+ return computed(() =>
47855
+ readRouteCurrent(route, params, exceptRoutes, alsoRoutes),
47856
+ );
47857
+ }, [route, paramsKey, exceptKey, alsoKey]);
47858
+ if (!currentSignal) {
47859
+ // Params no key can be made of (a date, an object): read on the spot, the
47860
+ // component then re-renders with the status of every route it reads.
47861
+ return readRouteCurrent(route, params, exceptRoutes, alsoRoutes);
47862
+ }
47863
+ return currentSignal.value;
47864
+ };
47865
+
47866
+ // Every signal is read even once the answer is known: reading is what
47867
+ // subscribes, and a route skipped today is the one whose change has to be heard
47868
+ // tomorrow.
47869
+ const readRouteCurrent = (route, params, exceptRoutes, alsoRoutes) => {
47870
+ const matching = route.matchingSignal.value;
47871
+ const paramsMatching = route.matchesParams(params);
47872
+ let someExceptMatching = false;
47873
+ for (const exceptRoute of exceptRoutes) {
47874
+ if (exceptRoute.matchingSignal.value) {
47875
+ someExceptMatching = true;
47876
+ }
47877
+ }
47878
+ let someAlsoMatching = false;
47879
+ for (const alsoRoute of alsoRoutes) {
47880
+ if (alsoRoute.matchingSignal.value) {
47881
+ someAlsoMatching = true;
47882
+ }
47883
+ }
47884
+ if (someExceptMatching) {
47885
+ return false;
47886
+ }
47887
+ return (matching && paramsMatching) || someAlsoMatching;
47888
+ };
47889
+
47890
+ const toRouteList = (routes) => {
47891
+ if (!routes) {
47892
+ return [];
47893
+ }
47894
+ return Array.isArray(routes) ? routes : [routes];
47895
+ };
47896
+
47897
+ // A list of routes as a key, since an inline `currentAlso={[A, B]}` is a new
47898
+ // array at every render of the parent.
47899
+ const routeIdByRoute = new WeakMap();
47900
+ let routeIdCount = 0;
47901
+ const routeListKey = (routes) => {
47902
+ let key = "";
47903
+ for (const route of routes) {
47904
+ let id = routeIdByRoute.get(route);
47905
+ if (id === undefined) {
47906
+ id = ++routeIdCount;
47907
+ routeIdByRoute.set(route, id);
47908
+ }
47909
+ key += `${id},`;
47910
+ }
47911
+ return key;
47912
+ };
47913
+
47786
47914
  /*
47787
47915
  * Custom hook to apply semi-transparent color when an element should be dimmed.
47788
47916
  *
@@ -48326,15 +48454,10 @@ const LinkWithRoute = ({
48326
48454
  ...rest
48327
48455
  }) => {
48328
48456
  const url = route.buildUrl(routeParams);
48329
- const {
48330
- matching
48331
- } = useRouteStatus(route);
48332
- const paramsAreMatching = route.matchesParams(routeParams);
48333
- const someExceptedRouteMatching = useSomeRouteMatching(currentExcept);
48334
- const someAlsoRouteMatching = useSomeRouteMatching(currentAlso);
48335
- // "Except" is a veto: a route named there is somewhere else, whatever the
48336
- // rest of the reading says.
48337
- const linkMatching = !someExceptedRouteMatching && (matching && paramsAreMatching || someAlsoRouteMatching);
48457
+ const linkMatching = useRouteCurrent(route, routeParams, {
48458
+ except: currentExcept,
48459
+ also: currentAlso
48460
+ });
48338
48461
  const innerCurrent = current || linkMatching;
48339
48462
  return jsx(Link, {
48340
48463
  href: url,
@@ -48343,22 +48466,6 @@ const LinkWithRoute = ({
48343
48466
  children: children || route.buildRelativeUrl(routeParams)
48344
48467
  });
48345
48468
  };
48346
- // A route, or a list of them, read as one answer: does any of them match.
48347
- // Reading matchingSignal during the render is what subscribes the component to
48348
- // it, so every route is read even once one has answered yes — stopping early
48349
- // would leave the component deaf to the ones it skipped.
48350
- const useSomeRouteMatching = routes => {
48351
- if (!routes) {
48352
- return false;
48353
- }
48354
- let someMatching = false;
48355
- for (const route of Array.isArray(routes) ? routes : [routes]) {
48356
- if (route.matchingSignal.value) {
48357
- someMatching = true;
48358
- }
48359
- }
48360
- return someMatching;
48361
- };
48362
48469
  const LinkPlain = props => {
48363
48470
  const defaultRef = useRef();
48364
48471
  props.ref = props.ref || defaultRef;
@@ -53442,11 +53549,7 @@ const ButtonWithRoute = props => {
53442
53549
  ...rest
53443
53550
  } = props;
53444
53551
  const url = route.buildUrl(routeParams);
53445
- const {
53446
- matching
53447
- } = useRouteStatus(route);
53448
- const paramsAreMatching = route.matchesParams(routeParams);
53449
- const linkMatching = matching && paramsAreMatching;
53552
+ const linkMatching = useRouteCurrent(route, routeParams);
53450
53553
 
53451
53554
  // Merged into whatever the caller already holds: a button can be forced into
53452
53555
  // a state for a demo and still learn its own current-ness from its route.
@@ -64744,10 +64847,24 @@ const useDialogProps = props => {
64744
64847
  // whichever show call ran, not just when a stray authored display
64745
64848
  // property is actually present — harmless the rest of the time.
64746
64849
  dialogEl.setAttribute("navi-hidden", "");
64747
- if (isTopLayerPopover) {
64748
- dialogEl.hidePopover();
64850
+ const hideNatively = () => {
64851
+ if (isTopLayerPopover) {
64852
+ dialogEl.hidePopover();
64853
+ } else {
64854
+ dialogEl.close();
64855
+ }
64856
+ };
64857
+ if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
64858
+ // Leaving the tree: hidden natively once the commit is over. Its
64859
+ // removal takes it out of the top layer and hands the focus to
64860
+ // nobody, where closing it now hands the focus to whatever held it
64861
+ // before the opening — often a trigger leaving in the same commit
64862
+ // (see giveFocusBackAfterCommit). What is left to close then is its
64863
+ // state: a <dialog> removed while open keeps its `open` attribute, and
64864
+ // a subtree <Suspense> parks and puts back would show it again.
64865
+ queueMicrotask(hideNatively);
64749
64866
  } else {
64750
- dialogEl.close();
64867
+ hideNatively();
64751
64868
  }
64752
64869
  // Held at the size it has right now, for the whole way out. cleanup()
64753
64870
  // below already stops the JS repositioning, but the size is CSS-driven
@@ -66416,7 +66533,13 @@ const usePopoverProps = props => {
66416
66533
  // reliably sufficient once a consumer's own CSS also sets display.
66417
66534
  popoverEl.setAttribute("navi-hidden", "");
66418
66535
  if (isTopLayer) {
66419
- popoverEl.hidePopover();
66536
+ // Left to its removal when the popover leaves the tree: that hides it
66537
+ // and hands the focus to nobody, where hidePopover() hands the focus
66538
+ // to whatever held it before the opening — often a trigger leaving in
66539
+ // the same commit (see giveFocusBackAfterCommit).
66540
+ if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
66541
+ popoverEl.hidePopover();
66542
+ }
66420
66543
  } else {
66421
66544
  openLocalPopoverCount = Math.max(0, openLocalPopoverCount - 1);
66422
66545
  }