@jsenv/navi 0.29.414 → 0.29.416

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.
@@ -14697,6 +14697,31 @@ const moveFocusTo = (
14697
14697
  });
14698
14698
  };
14699
14699
 
14700
+ /**
14701
+ * Gives the focus back to `target` once the commit a close is part of is over:
14702
+ * for a close caused by an unmount (UNMOUNT_EVENT_TYPE in
14703
+ * layout/open_controller.js), which runs from a cleanup while the tree leaving
14704
+ * with it is still in the document. Only its end tells a target leaving with
14705
+ * it — disconnected by then, and skipped — from one staying, and a focused
14706
+ * element that leaves makes the removal of its whole subtree restyle it.
14707
+ *
14708
+ * And only into a void: the close took the focus with it, so something holding
14709
+ * the focus by then — a page arriving that focused a field of its own — took it
14710
+ * after, and keeps it.
14711
+ */
14712
+ const giveFocusBackAfterCommit = (target, giveFocus) => {
14713
+ queueMicrotask(() => {
14714
+ if (!target || !target.isConnected) {
14715
+ return;
14716
+ }
14717
+ const focusedNow = document.activeElement;
14718
+ if (focusedNow && focusedNow !== document.body) {
14719
+ return;
14720
+ }
14721
+ giveFocus();
14722
+ });
14723
+ };
14724
+
14700
14725
  const focusTransferTarget = (target, focusVisible) => {
14701
14726
  moveFocusTo(target, { focusVisible });
14702
14727
  if (target.hasAttribute("navi-autofocus-select")) {
@@ -15251,6 +15276,12 @@ const createOpenController = (
15251
15276
  closeEvent,
15252
15277
  `closed by focusout -> let focus go away`,
15253
15278
  );
15279
+ } else if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
15280
+ // Left the tree: what held the focus before the opening may be
15281
+ // leaving with it (see giveFocusBackAfterCommit).
15282
+ giveFocusBackAfterCommit(focusTransfer.focusedElement, () => {
15283
+ focusTransfer.restoreFocus(closeEvent);
15284
+ });
15254
15285
  } else {
15255
15286
  // Only the mousedown, deliberately: a popup with no backdrop is
15256
15287
  // closed by a `pointerdown` that belongs to the page (see
@@ -18119,14 +18150,8 @@ const createCalloutManager = (
18119
18150
  };
18120
18151
  if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
18121
18152
  // Closed by a popup leaving the tree: the control may be leaving
18122
- // in the same commit, which only its end tells. A leaving control
18123
- // must not get the focus — the removal of a subtree holding it
18124
- // restyles the whole subtree.
18125
- queueMicrotask(() => {
18126
- if (focusTarget.isConnected) {
18127
- giveFocusBack();
18128
- }
18129
- });
18153
+ // with it (see giveFocusBackAfterCommit).
18154
+ giveFocusBackAfterCommit(focusTarget, giveFocusBack);
18130
18155
  } else {
18131
18156
  // Given before the callout leaves the document: removed while it
18132
18157
  // holds the focus, it sends a focusout with nowhere to go up
@@ -24455,6 +24480,21 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24455
24480
  }
24456
24481
  return result;
24457
24482
  };
24483
+ // A computed of the row with this id, one per id: what a render reads to
24484
+ // follow that row and no other. An upsert replaces only the items it changes
24485
+ // (see assign below), so a row nobody wrote keeps its object, and its
24486
+ // computed stays still while the rest of the store moves — read through
24487
+ // `select`, a render follows the whole store. Kept as long as the store,
24488
+ // like the rows it holds.
24489
+ const itemSignalById = new Map();
24490
+ const itemSignalForId = (id) => {
24491
+ let itemSignal = itemSignalById.get(id);
24492
+ if (!itemSignal) {
24493
+ itemSignal = computed(() => idMapSignal.value.get(id));
24494
+ itemSignalById.set(id, itemSignal);
24495
+ }
24496
+ return itemSignal;
24497
+ };
24458
24498
  const upsert = (...args) => {
24459
24499
  const mutationsMap = new Map(); // Map<itemId, propertyMutations>
24460
24500
  const triggerPropertyMutations = () => {
@@ -24845,6 +24885,7 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
24845
24885
  arraySignal,
24846
24886
  select,
24847
24887
  selectAll,
24888
+ itemSignalForId,
24848
24889
  upsert,
24849
24890
  drop,
24850
24891
 
@@ -26010,12 +26051,34 @@ const createResource = (
26010
26051
  idKey,
26011
26052
  uniqueKeys,
26012
26053
 
26013
- // Reactive reads, not hooks: they subscribe the render that calls them to
26014
- // the store's array signal. Nothing hook-shaped inside, so a loop or a
26015
- // condition is fine. `useAllByIds` skips the ids the store does not hold.
26054
+ // Reactive reads, not hooks: nothing hook-shaped inside, so a loop or a
26055
+ // condition is fine. Each subscribes the render that calls it to what it
26056
+ // returns: the rows asked for, each through a computed of that row (see
26057
+ // itemSignalForId in array_signal_store.js), or the whole store for
26058
+ // `useArray` — a card reading its own row stays still while the page next
26059
+ // to it loads rows of its own. `useAllByIds` skips the ids the store does
26060
+ // not hold.
26016
26061
  useArray: () => store.arraySignal.value,
26017
- useById: (id) => store.select(idKey, id),
26018
- useAllByIds: (idArray) => store.selectAll(idArray),
26062
+ useById: (id) => store.itemSignalForId(id).value,
26063
+ useAllByIds: (idArray) => {
26064
+ const items = [];
26065
+ for (const idOrProps of idArray) {
26066
+ let id = idOrProps;
26067
+ if (isProps(idOrProps) && !Object.hasOwn(idOrProps, idKey)) {
26068
+ // Named by a unique key: which row that is can only be read off
26069
+ // the whole store.
26070
+ const item = store.select(idOrProps);
26071
+ id = item ? item[idKey] : undefined;
26072
+ } else if (isProps(idOrProps)) {
26073
+ id = idOrProps[idKey];
26074
+ }
26075
+ const item = store.itemSignalForId(id).value;
26076
+ if (item) {
26077
+ items.push(item);
26078
+ }
26079
+ }
26080
+ return items;
26081
+ },
26019
26082
 
26020
26083
  withParams: undefined,
26021
26084
  one: undefined,
@@ -32251,7 +32314,11 @@ const useContentKeepingLeavingPages = branch => {
32251
32314
  // mounted with, or it would be mounted again.
32252
32315
  key = shown.key;
32253
32316
  page = shown.page;
32254
- } else if (shown && leavingPagesHold) {
32317
+ } else if (shown && leavingPagesHold && !document.querySelector(":modal")) {
32318
+ // Never while a modal dialog is open: it keeps everything but itself out
32319
+ // of reach — the page arriving could not even take the focus — for as long
32320
+ // as it is open, and one in a page kept while leaving stays open for the
32321
+ // whole movement. The page is then taken down at once, as without one.
32255
32322
  leavingRef.current.push({
32256
32323
  vnode: shown.vnode,
32257
32324
  page: shown.page,
@@ -32298,10 +32365,17 @@ const useContentKeepingLeavingPages = branch => {
32298
32365
  }
32299
32366
  entry.hidden = true;
32300
32367
  entry.page.leavingSignal.value = true;
32368
+ const focusedElement = document.activeElement;
32301
32369
  let node = start.nextSibling;
32302
32370
  while (node && node !== end) {
32303
32371
  if (node.nodeType === Node.ELEMENT_NODE) {
32304
32372
  node.setAttribute(LEAVING_ATTRIBUTE, "");
32373
+ // What the keyboard held in it goes where the page's removal would
32374
+ // have sent it: a hidden element keeps the focus, and would go on
32375
+ // receiving the keys pressed during the movement.
32376
+ if (node.contains(focusedElement)) {
32377
+ focusedElement.blur();
32378
+ }
32305
32379
  }
32306
32380
  node = node.nextSibling;
32307
32381
  }
@@ -45138,8 +45212,12 @@ const useInteractiveProps = (props, {
45138
45212
  }
45139
45213
  // Read by READONLY_CONSTRAINT to say how many runs are already out, and by
45140
45214
  // the action handlers below, which are where a run joins and leaves the
45141
- // count (a labelled block away, hence through the controller).
45142
- uiStateController.parallelGuard = parallelGuard;
45215
+ // count (a labelled block away, hence through the controller). None for a
45216
+ // control without an action of its own, by the same rule as being held
45217
+ // back: what it runs (the noop every such control has, a link's
45218
+ // navigation) is nothing the guard counts, and would hold a slot for its
45219
+ // length.
45220
+ uiStateController.parallelGuard = props.action ? parallelGuard : null;
45143
45221
  if (heldByParallelGuard) {
45144
45222
  controlHostProps["data-readonly-reason"] = "parallel-guard";
45145
45223
  }
@@ -45414,7 +45492,11 @@ const useInteractiveProps = (props, {
45414
45492
  // carried by every navi_action_* event of that run).
45415
45493
  uiStateController.pendingActionEvent = e.detail.event;
45416
45494
  uiStateController.actionInFlight = true;
45417
- uiStateController.parallelGuard?.claim(uiStateController);
45495
+ // Given back to the guard it was counted in, whatever the control
45496
+ // holds by then: one that lost its action mid-run has no guard left
45497
+ // to name, and the slot would stay taken.
45498
+ const countedIn = uiStateController.parallelGuard;
45499
+ countedIn?.claim(uiStateController);
45418
45500
  if (optimistic) {
45419
45501
  syncOptimisticAttribute(true);
45420
45502
  }
@@ -45432,7 +45514,7 @@ const useInteractiveProps = (props, {
45432
45514
  e.detail.addSideEffect((outcome, outcomeEvent) => {
45433
45515
  uiStateController.actionInFlight = false;
45434
45516
  uiStateController.runningActionSignal.value = null;
45435
- uiStateController.parallelGuard?.release(uiStateController);
45517
+ countedIn?.release(uiStateController);
45436
45518
  // The outcome callbacks are told from here, the run's side effect,
45437
45519
  // and not from the onnavi_action_* handlers below: the run can
45438
45520
  // outlive this control (its own answer, written to a store the
@@ -48965,6 +49047,90 @@ const BinderItemContext = createContext(null);
48965
49047
  */
48966
49048
  const NavContext = createContext(null);
48967
49049
 
49050
+ /**
49051
+ * Whether the page one is on is `route` with `params` — what a link or a button
49052
+ * pointing at a route shows as current.
49053
+ *
49054
+ * Read through a computed of that one answer, so the component reading it
49055
+ * re-renders when the answer flips, and not whenever the route's status moves:
49056
+ * a page arriving on the route with other params — one profile opening, under a
49057
+ * list of links to a hundred other profiles — starts the route matching and
49058
+ * changes its params, and leaves every one of those links exactly as it was.
49059
+ *
49060
+ * `except`: routes whose matching vetoes the answer — a route named there is
49061
+ * somewhere else, whatever the rest of the reading says. `also`: routes whose
49062
+ * matching grants it. Both take a route or a list of routes.
49063
+ */
49064
+ const useRouteCurrent = (route, params, { except, also } = {}) => {
49065
+ const exceptRoutes = toRouteList(except);
49066
+ const alsoRoutes = toRouteList(also);
49067
+ const paramsKey = getParamsCacheKey(params);
49068
+ const exceptKey = routeListKey(exceptRoutes);
49069
+ const alsoKey = routeListKey(alsoRoutes);
49070
+ const currentSignal = useMemo(() => {
49071
+ if (paramsKey === null) {
49072
+ return null;
49073
+ }
49074
+ return computed(() =>
49075
+ readRouteCurrent(route, params, exceptRoutes, alsoRoutes),
49076
+ );
49077
+ }, [route, paramsKey, exceptKey, alsoKey]);
49078
+ if (!currentSignal) {
49079
+ // Params no key can be made of (a date, an object): read on the spot, the
49080
+ // component then re-renders with the status of every route it reads.
49081
+ return readRouteCurrent(route, params, exceptRoutes, alsoRoutes);
49082
+ }
49083
+ return currentSignal.value;
49084
+ };
49085
+
49086
+ // Every signal is read even once the answer is known: reading is what
49087
+ // subscribes, and a route skipped today is the one whose change has to be heard
49088
+ // tomorrow.
49089
+ const readRouteCurrent = (route, params, exceptRoutes, alsoRoutes) => {
49090
+ const matching = route.matchingSignal.value;
49091
+ const paramsMatching = route.matchesParams(params);
49092
+ let someExceptMatching = false;
49093
+ for (const exceptRoute of exceptRoutes) {
49094
+ if (exceptRoute.matchingSignal.value) {
49095
+ someExceptMatching = true;
49096
+ }
49097
+ }
49098
+ let someAlsoMatching = false;
49099
+ for (const alsoRoute of alsoRoutes) {
49100
+ if (alsoRoute.matchingSignal.value) {
49101
+ someAlsoMatching = true;
49102
+ }
49103
+ }
49104
+ if (someExceptMatching) {
49105
+ return false;
49106
+ }
49107
+ return (matching && paramsMatching) || someAlsoMatching;
49108
+ };
49109
+
49110
+ const toRouteList = (routes) => {
49111
+ if (!routes) {
49112
+ return [];
49113
+ }
49114
+ return Array.isArray(routes) ? routes : [routes];
49115
+ };
49116
+
49117
+ // A list of routes as a key, since an inline `currentAlso={[A, B]}` is a new
49118
+ // array at every render of the parent.
49119
+ const routeIdByRoute = new WeakMap();
49120
+ let routeIdCount = 0;
49121
+ const routeListKey = (routes) => {
49122
+ let key = "";
49123
+ for (const route of routes) {
49124
+ let id = routeIdByRoute.get(route);
49125
+ if (id === undefined) {
49126
+ id = ++routeIdCount;
49127
+ routeIdByRoute.set(route, id);
49128
+ }
49129
+ key += `${id},`;
49130
+ }
49131
+ return key;
49132
+ };
49133
+
48968
49134
  /*
48969
49135
  * Custom hook to apply semi-transparent color when an element should be dimmed.
48970
49136
  *
@@ -49514,15 +49680,17 @@ const LinkWithRoute = ({
49514
49680
  assertRoute(route);
49515
49681
  }
49516
49682
  const url = route.buildUrl(routeParams);
49517
- const {
49518
- matching
49519
- } = useRouteStatus(route);
49520
- const paramsAreMatching = route.matchesParams(routeParams);
49521
- const someExceptedRouteMatching = useSomeRouteMatching(currentExcept);
49522
- const someAlsoRouteMatching = useSomeRouteMatching(currentAlso);
49523
- // "Except" is a veto: a route named there is somewhere else, whatever the
49524
- // rest of the reading says.
49525
- const linkMatching = !someExceptedRouteMatching && (matching && paramsAreMatching || someAlsoRouteMatching);
49683
+ {
49684
+ for (const amendingRoute of [currentExcept, currentAlso].flat()) {
49685
+ if (amendingRoute) {
49686
+ assertRoute(amendingRoute);
49687
+ }
49688
+ }
49689
+ }
49690
+ const linkMatching = useRouteCurrent(route, routeParams, {
49691
+ except: currentExcept,
49692
+ also: currentAlso
49693
+ });
49526
49694
  const innerCurrent = current || linkMatching;
49527
49695
  return jsx(Link, {
49528
49696
  href: url,
@@ -49531,25 +49699,6 @@ const LinkWithRoute = ({
49531
49699
  children: children || route.buildRelativeUrl(routeParams)
49532
49700
  });
49533
49701
  };
49534
- // A route, or a list of them, read as one answer: does any of them match.
49535
- // Reading matchingSignal during the render is what subscribes the component to
49536
- // it, so every route is read even once one has answered yes — stopping early
49537
- // would leave the component deaf to the ones it skipped.
49538
- const useSomeRouteMatching = routes => {
49539
- if (!routes) {
49540
- return false;
49541
- }
49542
- let someMatching = false;
49543
- for (const route of Array.isArray(routes) ? routes : [routes]) {
49544
- {
49545
- assertRoute(route);
49546
- }
49547
- if (route.matchingSignal.value) {
49548
- someMatching = true;
49549
- }
49550
- }
49551
- return someMatching;
49552
- };
49553
49702
  const LinkPlain = props => {
49554
49703
  const defaultRef = useRef();
49555
49704
  props.ref = props.ref || defaultRef;
@@ -54636,11 +54785,7 @@ const ButtonWithRoute = props => {
54636
54785
  assertRoute(route);
54637
54786
  }
54638
54787
  const url = route.buildUrl(routeParams);
54639
- const {
54640
- matching
54641
- } = useRouteStatus(route);
54642
- const paramsAreMatching = route.matchesParams(routeParams);
54643
- const linkMatching = matching && paramsAreMatching;
54788
+ const linkMatching = useRouteCurrent(route, routeParams);
54644
54789
 
54645
54790
  // Merged into whatever the caller already holds: a button can be forced into
54646
54791
  // a state for a demo and still learn its own current-ness from its route.
@@ -66073,10 +66218,24 @@ const useDialogProps = props => {
66073
66218
  // whichever show call ran, not just when a stray authored display
66074
66219
  // property is actually present — harmless the rest of the time.
66075
66220
  dialogEl.setAttribute("navi-hidden", "");
66076
- if (isTopLayerPopover) {
66077
- dialogEl.hidePopover();
66221
+ const hideNatively = () => {
66222
+ if (isTopLayerPopover) {
66223
+ dialogEl.hidePopover();
66224
+ } else {
66225
+ dialogEl.close();
66226
+ }
66227
+ };
66228
+ if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
66229
+ // Leaving the tree: hidden natively once the commit is over. Its
66230
+ // removal takes it out of the top layer and hands the focus to
66231
+ // nobody, where closing it now hands the focus to whatever held it
66232
+ // before the opening — often a trigger leaving in the same commit
66233
+ // (see giveFocusBackAfterCommit). What is left to close then is its
66234
+ // state: a <dialog> removed while open keeps its `open` attribute, and
66235
+ // a subtree <Suspense> parks and puts back would show it again.
66236
+ queueMicrotask(hideNatively);
66078
66237
  } else {
66079
- dialogEl.close();
66238
+ hideNatively();
66080
66239
  }
66081
66240
  // Held at the size it has right now, for the whole way out. cleanup()
66082
66241
  // below already stops the JS repositioning, but the size is CSS-driven
@@ -67781,7 +67940,13 @@ const usePopoverProps = props => {
67781
67940
  // reliably sufficient once a consumer's own CSS also sets display.
67782
67941
  popoverEl.setAttribute("navi-hidden", "");
67783
67942
  if (isTopLayer) {
67784
- popoverEl.hidePopover();
67943
+ // Left to its removal when the popover leaves the tree: that hides it
67944
+ // and hands the focus to nobody, where hidePopover() hands the focus
67945
+ // to whatever held it before the opening — often a trigger leaving in
67946
+ // the same commit (see giveFocusBackAfterCommit).
67947
+ if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
67948
+ popoverEl.hidePopover();
67949
+ }
67785
67950
  } else {
67786
67951
  openLocalPopoverCount = Math.max(0, openLocalPopoverCount - 1);
67787
67952
  }