@jsenv/navi 0.29.398 → 0.29.399

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.
@@ -18348,6 +18348,33 @@ const syncOwnedResourceToSignals = (
18348
18348
 
18349
18349
  // Global signal registry for route template detection
18350
18350
  const globalSignalRegistry = new Map();
18351
+
18352
+ // The values some states are about to hold, answered in place of what they
18353
+ // hold for the length of one readStateAsIf.
18354
+ let valuesAsIf = null;
18355
+ /**
18356
+ * Runs `read` as if each state in `values` held the value given for it: the
18357
+ * address a press is about to write, read before it is written. Nothing is
18358
+ * written, so whoever watches those states hears nothing, and `read`
18359
+ * subscribes to nothing.
18360
+ *
18361
+ * Only a read of the state itself is answered this way. A `computed` derived
18362
+ * from one answers what it last computed — or, evaluated for the first time
18363
+ * inside `read`, caches a value derived from this answer: `read` reads the
18364
+ * states it depends on directly (a route action's params do).
18365
+ *
18366
+ * @param {Map<import("@preact/signals").Signal, any>} values
18367
+ * @param {() => any} read
18368
+ */
18369
+ const readStateAsIf = (values, read) => {
18370
+ const previous = valuesAsIf;
18371
+ valuesAsIf = values;
18372
+ try {
18373
+ return untracked(read);
18374
+ } finally {
18375
+ valuesAsIf = previous;
18376
+ }
18377
+ };
18351
18378
  let signalIdCounter = 0;
18352
18379
  const generateSignalId = () => {
18353
18380
  const id = signalIdCounter++;
@@ -18671,6 +18698,9 @@ const stateSignal = (defaultValue, options = {}) => {
18671
18698
  const valueDescriptor = Object.getOwnPropertyDescriptor(signalProto, "value");
18672
18699
  Object.defineProperty(preactSignal, "value", {
18673
18700
  get() {
18701
+ if (valuesAsIf !== null && valuesAsIf.has(preactSignal)) {
18702
+ return valuesAsIf.get(preactSignal);
18703
+ }
18674
18704
  return valueDescriptor.get.call(preactSignal);
18675
18705
  },
18676
18706
  set(newValue) {
@@ -27055,7 +27085,8 @@ const route = (
27055
27085
  };
27056
27086
  // Asks ahead of the arrival for what this route's actions ask without the
27057
27087
  // address — a page's code, a read without params (see routeAction). An
27058
- // action keyed by a param waits for the arrival: a prefetch has no address.
27088
+ // action keyed by a param waits for the arrival: a prefetch has no address
27089
+ // (preloadState is the one that has one).
27059
27090
  route.preload = () => {
27060
27091
  const preloadSet = routePreloadMap.get(route);
27061
27092
  if (!preloadSet) {
@@ -27635,6 +27666,40 @@ const preloadUrl = (url) => {
27635
27666
  }
27636
27667
  }
27637
27668
  };
27669
+
27670
+ const routeStatePreloadMap = new WeakMap();
27671
+ const registerRouteStatePreload = (route, preload) => {
27672
+ let preloadSet = routeStatePreloadMap.get(route);
27673
+ if (!preloadSet) {
27674
+ preloadSet = new Set();
27675
+ routeStatePreloadMap.set(route, preloadSet);
27676
+ }
27677
+ preloadSet.add(preload);
27678
+ };
27679
+ /**
27680
+ * Preloads what the routes one is on will ask for once `signal` holds
27681
+ * `value`: the address a popup's opening is about to write (see
27682
+ * open_controller.js), known at the press that opens it. Only the routes
27683
+ * matching now are asked — writing a state keeps one on the same page.
27684
+ */
27685
+ const preloadState = (signal, value) => {
27686
+ if (!activeRouteSet) {
27687
+ return;
27688
+ }
27689
+ const values = new Map([[signal, value]]);
27690
+ for (const route of activeRouteSet) {
27691
+ if (!route.matchingSignal.peek()) {
27692
+ continue;
27693
+ }
27694
+ const preloadSet = routeStatePreloadMap.get(route);
27695
+ if (!preloadSet) {
27696
+ continue;
27697
+ }
27698
+ for (const preload of preloadSet) {
27699
+ preload(values);
27700
+ }
27701
+ }
27702
+ };
27638
27703
  const setupRoutes = (routes) => {
27639
27704
  if (setupRoutesCalled) {
27640
27705
  throw new Error(
@@ -34806,10 +34871,14 @@ const writeCover = (edge, element, size) => {
34806
34871
  * the address, which is also what makes it worth fetching AHEAD of the
34807
34872
  * arrival: a link to the route preruns it when the pointer or the focus
34808
34873
  * reaches the link (see docs/dynamic_import.md). An action whose params
34809
- * come from the address is never prefetched — a prefetch has no address.
34874
+ * come from the address is not prefetched by a link — a link's prefetch has
34875
+ * no address. It is by the press that opens a popup bound to a state these
34876
+ * params read: that address is the current one with the state set to the
34877
+ * popup's value (see `prefetch` on Button).
34810
34878
  * @param {object} [options]
34811
- * @param {boolean} [options.prefetch=true] - `false` keeps a param-less action
34812
- * from being prerun on intent, when the read is not worth a hover.
34879
+ * @param {boolean} [options.prefetch=true] - `false` keeps the action from
34880
+ * being prerun ahead of what asks for it, when the read is not worth a
34881
+ * hover or a press that may not end in an opening.
34813
34882
  */
34814
34883
  const routeAction = (
34815
34884
  routeOrRoutes,
@@ -34838,27 +34907,47 @@ const routeAction = (
34838
34907
  },
34839
34908
  options,
34840
34909
  );
34841
- if (!paramsEffect && prefetch) {
34910
+ if (prefetch) {
34842
34911
  for (const route of routes) {
34843
- registerRoutePreload(route, () => {
34844
- prefetchParamless(action);
34845
- });
34912
+ if (paramsEffect) {
34913
+ registerRouteStatePreload(route, (values) => {
34914
+ prefetchAsIf(action, paramsEffect, values);
34915
+ });
34916
+ } else {
34917
+ registerRoutePreload(route, () => {
34918
+ prerunAhead(action.bindParams(true));
34919
+ });
34920
+ }
34846
34921
  }
34847
34922
  }
34848
34923
  return actionBoundToRoute;
34849
34924
  };
34850
34925
 
34851
- // The instance the effect above runs on the match is the one bound to `true`
34852
- // (the default params), so a prerun here is what the arrival promotes to a
34853
- // run. A prefetch that fails is not reported: nothing on screen asked for it,
34854
- // and the failure stays on the instance, where a run asks again — a FAILED
34855
- // action is run, only RUNNING and COMPLETED are left alone — and the arrival
34856
- // shows its own. It is left FAILED on purpose, not reset: the press that
34857
- // brings the arrival also focuses the link, so a prerun can be in flight when
34858
- // the arrival promotes it, and a reset landing after that would pull the
34859
- // answer from under the page that is reading it.
34860
- const prefetchParamless = (action) => {
34861
- const instance = action.bindParams(true);
34926
+ // The params as they will read once the states hold what they are about to
34927
+ // (see readStateAsIf), prerun only when that changes them: params the state
34928
+ // leaves alone are the ones the action already runs on — or is meant not to,
34929
+ // and a prerun would retry a FAILED one behind the screen's back.
34930
+ const prefetchAsIf = (action, paramsEffect, values) => {
34931
+ const params = readStateAsIf(values, paramsEffect);
34932
+ if (!params) {
34933
+ return;
34934
+ }
34935
+ if (compareTwoJsValues(params, untracked(paramsEffect))) {
34936
+ return;
34937
+ }
34938
+ prerunAhead(action.bindParams(params));
34939
+ };
34940
+
34941
+ // The instance prerun is the one the arrival runs on — the one bound to the
34942
+ // params it reads (`true` for a param-less action) — so the arrival promotes
34943
+ // it to a run. A prefetch that fails is not reported: nothing on screen asked
34944
+ // for it, and the failure stays on the instance, where a run asks again — a
34945
+ // FAILED action is run, only RUNNING and COMPLETED are left alone — and the
34946
+ // arrival shows its own. It is left FAILED on purpose, not reset: a prerun is
34947
+ // often still in flight when the arrival promotes it (the press that brings
34948
+ // the arrival focuses the link, or is the one that asked), and a reset
34949
+ // landing after that would pull the answer from under the page reading it.
34950
+ const prerunAhead = (instance) => {
34862
34951
  let result;
34863
34952
  try {
34864
34953
  result = instance.prerun({ reason: "intent" });
@@ -35872,6 +35961,30 @@ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
35872
35961
  },
35873
35962
  };
35874
35963
  });
35964
+ /**
35965
+ * Tells the popup a source opens that a press on it has started, ahead of the
35966
+ * release that runs the command: what the opening will read can be asked for
35967
+ * now (see open_controller.js). A press rather than a hover, because what
35968
+ * that reads is keyed by the popup — one sheet per card — and a pointer
35969
+ * crossing a list of cards has chosen none of them; on a touch screen the
35970
+ * press is the first thing the finger says anyway.
35971
+ *
35972
+ * The target is found the way --navi-open finds it, but silently: nothing
35973
+ * runs here, and a source with no target says so at the release.
35974
+ */
35975
+ const announceOpeningPress = (source, command) => {
35976
+ if (command !== "--navi-open" && command !== "--navi-toggle") {
35977
+ return;
35978
+ }
35979
+ const commandFor = source.getAttribute("commandfor");
35980
+ const target = commandFor
35981
+ ? document.getElementById(commandFor)
35982
+ : resolveExplicitTarget(source) || resolveClosestExpandable(source);
35983
+ if (!target) {
35984
+ return;
35985
+ }
35986
+ dispatchCustomEvent(target, "navi_open_press");
35987
+ };
35875
35988
  // "--navi-close:all" closes every expandable above the source, nearest first —
35876
35989
  // a link leaving from a badge shown over a sheet leaves both. A surface that
35877
35990
  // refuses (a form asking about its changes) keeps what is above it open too:
@@ -37330,7 +37443,7 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
37330
37443
  return;
37331
37444
  }
37332
37445
  if (opened) {
37333
- signal.value = popupValue === undefined ? true : popupValue;
37446
+ signal.value = readOpenValue(popupValue);
37334
37447
  return;
37335
37448
  }
37336
37449
  const closedValue = readClosedValue(signalValue);
@@ -37362,6 +37475,9 @@ const readOpened = (signalValue, popupValue) => {
37362
37475
  }
37363
37476
  return signalValue === popupValue;
37364
37477
  };
37478
+ const readOpenValue = (popupValue) => {
37479
+ return popupValue === undefined ? true : popupValue;
37480
+ };
37365
37481
  const readClosedValue = (openSignalValue) => {
37366
37482
  return openSignalValue === true ? false : undefined;
37367
37483
  };
@@ -37448,6 +37564,29 @@ const useOpenPropsEffectOnOpenController = (
37448
37564
  }
37449
37565
  }
37450
37566
  : null;
37567
+ // A press that will open this popup has started (see announceOpeningPress
37568
+ // in commands.js). What the opening writes — the popup's value, in its
37569
+ // signal — is known before the release, so what the screen reads off it is
37570
+ // asked for now (see preloadState).
37571
+ useEffect(() => {
37572
+ if (!signal) {
37573
+ return undefined;
37574
+ }
37575
+ const element = openController.getElement?.();
37576
+ if (!element) {
37577
+ return undefined;
37578
+ }
37579
+ const onOpenPress = () => {
37580
+ if (openController.opened) {
37581
+ return;
37582
+ }
37583
+ preloadState(signal, readOpenValue(value));
37584
+ };
37585
+ element.addEventListener("navi_open_press", onOpenPress);
37586
+ return () => {
37587
+ element.removeEventListener("navi_open_press", onOpenPress);
37588
+ };
37589
+ }, [signal, value]);
37451
37590
  // Tracks whether the effect below has ever run before — only the very
37452
37591
  // first run gets the "mount already open" treatment (`open` truthy from
37453
37592
  // the start, or the uncontrolled, mount-only `defaultOpen`); every
@@ -38180,17 +38319,30 @@ const armOutsidePressClose = (
38180
38319
  * content ahead of the click that will open it. Deferring the build to the
38181
38320
  * opening puts its whole cost in the frame right after the click — the frame
38182
38321
  * where a delay is felt hardest — while the ~100-300ms between hovering a
38183
- * trigger and pressing it are free. The warming render is asynchronous
38184
- * (batched, not flushed): nothing here needs the content in the DOM before
38185
- * the click, only before the open that follows it.
38186
- *
38187
- * "while-opened" content is never warmed. That mode promises two things
38188
- * warming would break: the content is built at open time (so a `defaultValue`
38189
- * read at build time is fresh, not seeded at pointer-enter time), and it is
38190
- * only ever mounted between an open and a close — unmounting happens on close,
38191
- * so a warmed popup that never opens would keep its content in the document
38192
- * indefinitely. Callers lean on that guarantee (e.g. several pickers sharing
38193
- * one set of content ids because only one content exists at a time).
38322
+ * trigger and pressing it are free. The warming render starts at the frame
38323
+ * after the one that follows the intent: nothing here needs the content in the
38324
+ * DOM before the click, only before the open that follows it, and on a touch
38325
+ * screen `pointerenter` arrives together with the `pointerdown` — a build
38326
+ * started there would hold back the frame that shows the press. Two animation
38327
+ * frames, never a timeout: browsers hold timers back while a finger is down,
38328
+ * and a timeout started by the press fires at its release, on top of the click.
38329
+ *
38330
+ * "while-opened" content is warmed by a press on the anchor, never by a hover
38331
+ * or a focus. That mode promises content built fresh for the gesture that
38332
+ * opens it, and mounted only while that gesture and its opening last: callers
38333
+ * lean on it (several pickers sharing one set of content ids, because only one
38334
+ * content exists at a time). A pointer crossing four anchors would build four
38335
+ * contents; a press is the start of one opening. So the press builds it, and
38336
+ * the press ending without an open throws it away — its click reaching the
38337
+ * document with the popup still closed, no click coming after the release,
38338
+ * the browser taking the gesture (`pointercancel`), or the next press
38339
+ * starting, before any of its own handlers run: one press at a time holds a
38340
+ * content. A keyboard opening
38341
+ * builds at open time, as without warming. What this gives up: a press-warmed
38342
+ * content is built before `onOpen` runs. The popups seeding their content from
38343
+ * `onOpen` are opened ON something by a command, whose press is not on their
38344
+ * anchor, so they are not warmed; one pressed on its own anchor that still
38345
+ * seeds from `onOpen` reads the value from before it.
38194
38346
  */
38195
38347
 
38196
38348
 
@@ -38232,26 +38384,35 @@ const usePopupContentMount = (
38232
38384
  { mount = MOUNT_DEFAULT, anchor },
38233
38385
  ) => {
38234
38386
  const mountedAlways = mount === "always";
38235
- const [contentMounted, setContentMounted] = useState(
38387
+ const [contentMounted, setContentMountedState] = useState(
38236
38388
  () => mountedAlways || openController.opened,
38237
38389
  );
38238
- openController.mountContent = contentMounted
38239
- ? null
38240
- : () => {
38241
- const popupElement = ref?.current;
38242
- if (popupElement) {
38243
- popupsMountingContentForOpen.add(popupElement);
38244
- }
38245
- try {
38246
- flushSyncRendering(() => {
38247
- setContentMounted(true);
38248
- });
38249
- } finally {
38250
- if (popupElement) {
38251
- popupsMountingContentForOpen.delete(popupElement);
38252
- }
38253
- }
38254
- };
38390
+ // What was last asked for, ahead of the render that performs it: an open
38391
+ // landing in between (a press-warmed content given up just before the open
38392
+ // that wanted it) must still find it has to build.
38393
+ const contentMountedRef = useRef(contentMounted);
38394
+ const setContentMounted = (value) => {
38395
+ contentMountedRef.current = value;
38396
+ setContentMountedState(value);
38397
+ };
38398
+ openController.mountContent = () => {
38399
+ if (contentMountedRef.current) {
38400
+ return;
38401
+ }
38402
+ const popupElement = ref?.current;
38403
+ if (popupElement) {
38404
+ popupsMountingContentForOpen.add(popupElement);
38405
+ }
38406
+ try {
38407
+ flushSyncRendering(() => {
38408
+ setContentMounted(true);
38409
+ });
38410
+ } finally {
38411
+ if (popupElement) {
38412
+ popupsMountingContentForOpen.delete(popupElement);
38413
+ }
38414
+ }
38415
+ };
38255
38416
  openController.unmountContent =
38256
38417
  mount === "while-opened"
38257
38418
  ? () => {
@@ -38289,41 +38450,140 @@ const usePopupContentMount = (
38289
38450
  cancelIdle(idleId);
38290
38451
  };
38291
38452
  }, [mount, contentMounted]);
38292
- // Warm on intent (see the top comment; "while-opened" is excluded there).
38293
- // The anchor accepts the same shapes Popover resolves at open time — a
38294
- // string id, a ref, an element — but is resolved here at effect time: an id
38295
- // that matches nothing yet simply doesn't warm, the open still mounts the
38296
- // content like it always does.
38453
+ // Warm on intent (see the top comment).
38297
38454
  useEffect(() => {
38298
38455
  if (contentMounted || !anchor || mount === "while-opened") {
38299
38456
  return undefined;
38300
38457
  }
38301
- const anchorElement =
38302
- typeof anchor === "string"
38303
- ? document.getElementById(anchor)
38304
- : // A ref is unwrapped even when it holds nothing: an expandable with
38305
- // no UI part hands an empty ref over, and the ref object itself is
38306
- // truthy — it would reach addEventListener below and throw.
38307
- "current" in anchor
38308
- ? anchor.current
38309
- : anchor;
38458
+ const anchorElement = resolveAnchorElement(anchor);
38310
38459
  if (!anchorElement) {
38311
38460
  return undefined;
38312
38461
  }
38462
+ let cancelWarm = null;
38313
38463
  const warm = () => {
38314
- setContentMounted(true);
38464
+ if (cancelWarm) {
38465
+ return;
38466
+ }
38467
+ cancelWarm = requestFrameAfterNext(() => {
38468
+ setContentMounted(true);
38469
+ });
38315
38470
  };
38316
38471
  anchorElement.addEventListener("pointerenter", warm);
38317
38472
  anchorElement.addEventListener("focusin", warm);
38318
38473
  return () => {
38474
+ cancelWarm?.();
38319
38475
  anchorElement.removeEventListener("pointerenter", warm);
38320
38476
  anchorElement.removeEventListener("focusin", warm);
38321
38477
  };
38322
38478
  }, [contentMounted, anchor, mount]);
38479
+ // Warm on the press, for "while-opened" (see the top comment). Listening for
38480
+ // as long as the anchor is there, not only while the content is unmounted:
38481
+ // the press that throws a warmed content away can be a new press on this
38482
+ // same anchor, and it must warm again.
38483
+ useEffect(() => {
38484
+ if (!anchor || mount !== "while-opened") {
38485
+ return undefined;
38486
+ }
38487
+ const anchorElement = resolveAnchorElement(anchor);
38488
+ if (!anchorElement) {
38489
+ return undefined;
38490
+ }
38491
+ let stopPressWarm = null;
38492
+ const onPointerDown = (pointerdownEvent) => {
38493
+ if (pointerdownEvent.button !== 0 || contentMountedRef.current) {
38494
+ return;
38495
+ }
38496
+ const cancelBuild = requestFrameAfterNext(() => {
38497
+ setContentMounted(true);
38498
+ });
38499
+ const stopWatching = watchPressEnd(() => {
38500
+ stopPressWarm = null;
38501
+ cancelBuild();
38502
+ if (!openController.opened) {
38503
+ setContentMounted(false);
38504
+ }
38505
+ });
38506
+ stopPressWarm = () => {
38507
+ cancelBuild();
38508
+ stopWatching();
38509
+ };
38510
+ };
38511
+ anchorElement.addEventListener("pointerdown", onPointerDown);
38512
+ return () => {
38513
+ stopPressWarm?.();
38514
+ anchorElement.removeEventListener("pointerdown", onPointerDown);
38515
+ };
38516
+ }, [anchor, mount]);
38323
38517
 
38324
38518
  return contentMounted;
38325
38519
  };
38326
38520
 
38521
+ // The anchor accepts the same shapes Popover resolves at open time — a string
38522
+ // id, a ref, an element — but is resolved at effect time: an id that matches
38523
+ // nothing yet simply doesn't warm, the open still mounts the content.
38524
+ const resolveAnchorElement = (anchor) => {
38525
+ if (typeof anchor === "string") {
38526
+ return document.getElementById(anchor);
38527
+ }
38528
+ // A ref is unwrapped even when it holds nothing: an expandable with no UI
38529
+ // part hands an empty ref over, and the ref object itself is truthy — it
38530
+ // would reach addEventListener and throw.
38531
+ if ("current" in anchor) {
38532
+ return anchor.current;
38533
+ }
38534
+ return anchor;
38535
+ };
38536
+
38537
+ // The next frame paints what the input just changed (the pressed trigger); the
38538
+ // callback runs at the start of the one after.
38539
+ const requestFrameAfterNext = (callback) => {
38540
+ let frame = requestAnimationFrame(() => {
38541
+ frame = requestAnimationFrame(callback);
38542
+ });
38543
+ return () => {
38544
+ cancelAnimationFrame(frame);
38545
+ };
38546
+ };
38547
+
38548
+ // How long the click of a released press may take to arrive. Nothing tells a
38549
+ // page that a release brings no click — a press that became a drag has its
38550
+ // click swallowed (suppressClickAfterGesture in @jsenv/dom) — so past this the
38551
+ // press is taken to have ended without one. Longer than the 300ms a browser
38552
+ // still waits before the click of a tap on a page not sized for phones.
38553
+ const PRESS_CLICK_WAIT_MS = 500;
38554
+
38555
+ // Installed during the press's own pointerdown: the capture listeners below
38556
+ // only hear the presses after it.
38557
+ const watchPressEnd = (onEnd) => {
38558
+ let timeout;
38559
+ const stop = () => {
38560
+ clearTimeout(timeout);
38561
+ document.removeEventListener("click", onClick);
38562
+ document.removeEventListener("pointerup", onPointerUp, { capture: true });
38563
+ document.removeEventListener("pointercancel", end, { capture: true });
38564
+ document.removeEventListener("pointerdown", end, { capture: true });
38565
+ };
38566
+ const end = () => {
38567
+ stop();
38568
+ onEnd();
38569
+ };
38570
+ // Bubble phase, on the document: every click handler that could open the
38571
+ // popup has run by then.
38572
+ const onClick = (clickEvent) => {
38573
+ if (isPressDrivenClick(clickEvent)) {
38574
+ end();
38575
+ }
38576
+ };
38577
+ const onPointerUp = () => {
38578
+ timeout = setTimeout(end, PRESS_CLICK_WAIT_MS);
38579
+ };
38580
+ document.addEventListener("click", onClick);
38581
+ document.addEventListener("pointerup", onPointerUp, { capture: true });
38582
+ document.addEventListener("pointercancel", end, { capture: true });
38583
+ document.addEventListener("pointerdown", end, { capture: true });
38584
+ return stop;
38585
+ };
38586
+
38327
38587
  /**
38328
38588
  * A variant of useLayoutEffect that accounts for ancestor <dialog>/<details>
38329
38589
  * or popover visibility.
@@ -47106,6 +47366,31 @@ const usePreloadOnIntent = (ref, href, prefetch = true) => {
47106
47366
  }, [ref, href, prefetch]);
47107
47367
  };
47108
47368
 
47369
+ /**
47370
+ * What a popup's opening will read, asked for when the press on the element
47371
+ * whose command opens it starts (see announceOpeningPress): the popup, the
47372
+ * value it opens on and so the address it will write are all known before
47373
+ * the release.
47374
+ */
47375
+ const usePreloadOpeningOnPress = (ref, command, prefetch = true) => {
47376
+ useEffect(() => {
47377
+ if (!prefetch || !command) {
47378
+ return undefined;
47379
+ }
47380
+ const element = ref.current;
47381
+ if (!element) {
47382
+ return undefined;
47383
+ }
47384
+ const onPointerDown = () => {
47385
+ announceOpeningPress(element, command);
47386
+ };
47387
+ element.addEventListener("pointerdown", onPointerDown);
47388
+ return () => {
47389
+ element.removeEventListener("pointerdown", onPointerDown);
47390
+ };
47391
+ }, [ref, command, prefetch]);
47392
+ };
47393
+
47109
47394
  /**
47110
47395
  * Hook that reactively checks if a URL is visited.
47111
47396
  * Re-renders when the visited URL set changes.
@@ -51115,7 +51400,8 @@ const useExpandableContext = partName => {
51115
51400
  * content — and rebuilds it from scratch on every expansion. Under the
51116
51401
  * other three, intent on the UI part (pointer entering it, focus landing in
51117
51402
  * it) builds the content ahead of the click; `"while-opened"` content is
51118
- * never warmed, it is built at the open.
51403
+ * built by a press on it only, and thrown away if that press does not
51404
+ * expand it.
51119
51405
  */
51120
51406
  const Expandable = props => {
51121
51407
  import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -52427,6 +52713,9 @@ const ButtonUI = props => {
52427
52713
  // Nothing to prefetch on the way to another document: the routes do not
52428
52714
  // lead there.
52429
52715
  usePreloadOnIntent(ref, href, isDocument ? false : prefetch);
52716
+ // A press the button refuses opens nothing: nothing to ask ahead for.
52717
+ const refusesPress = basePseudoState[":read-only"] || basePseudoState[":disabled"] || loading;
52718
+ usePreloadOpeningOnPress(ref, props.command, prefetch && !refusesPress);
52430
52719
  useAccentColorAttributes(ref, null, {
52431
52720
  elementSelector: visualSelector
52432
52721
  });
@@ -52921,8 +53210,11 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
52921
53210
  * pointer or the focus arrives, ahead of the press (see
52922
53211
  * docs/dynamic_import.md): the route actions that ask nothing of the
52923
53212
  * address — a page's code, a read without params — are prerun; one keyed by
52924
- * a param waits for the arrival. `false` for a destination not worth
52925
- * fetching on a hover.
53213
+ * a param waits for the arrival. With a `--navi-open`/`--navi-toggle`
53214
+ * command instead, the press — not the hover: what it asks is keyed by the
53215
+ * popup — preruns the route actions whose params the popup's opening
53216
+ * changes, read as if its `signal` held its `value`. `false` for a
53217
+ * destination not worth fetching ahead.
52926
53218
  * @param {boolean} [pressableDuringRouteTransition] Keep answering presses
52927
53219
  * while a route transition plays: what a movement photographs goes deaf to
52928
53220
  * the pointer for its whole length, and the door that opened the page — a
@@ -63805,9 +64097,10 @@ const css$E = /* css */`
63805
64097
  * the cost on the critical render. `"while-opened"` throws them away once
63806
64098
  * the popup has finished closing, for content whose fresh state is its
63807
64099
  * initial state: an uncontrolled field seeded from a `defaultValue` that
63808
- * changed while the popup was closed. Whatever the value, intent on the
63809
- * anchor (pointer entering it, focus landing in it) builds the content
63810
- * ahead of the click.
64100
+ * changed while the popup was closed. Intent on the anchor (pointer
64101
+ * entering it, focus landing in it) builds the content ahead of the click;
64102
+ * under `"while-opened"`, only a press on it does, and a press that does not
64103
+ * open the popup throws it away.
63811
64104
  * @param {import("ignore:preact").ComponentChildren} props.children
63812
64105
  */
63813
64106
  const Dialog = props => {
@@ -65567,9 +65860,10 @@ const css$D = /* css */`
65567
65860
  * the cost on the critical render. `"while-opened"` throws them away once
65568
65861
  * the popup has finished closing, for content whose fresh state is its
65569
65862
  * initial state: an uncontrolled field seeded from a `defaultValue` that
65570
- * changed while the popup was closed. Whatever the value, intent on the
65571
- * anchor (pointer entering it, focus landing in it) builds the content
65572
- * ahead of the click.
65863
+ * changed while the popup was closed. Intent on the anchor (pointer
65864
+ * entering it, focus landing in it) builds the content ahead of the click;
65865
+ * under `"while-opened"`, only a press on it does, and a press that does not
65866
+ * open the popup throws it away.
65573
65867
  * @param {import("ignore:preact").ComponentChildren} props.children
65574
65868
  */
65575
65869
  const Popover = props => {
@@ -80475,6 +80769,9 @@ const css$n = /* css */`.navi_split_button {
80475
80769
  * defaultValue?: any,
80476
80770
  * label?: import("ignore:preact").ComponentChildren,
80477
80771
  * action?: (value: any, event: Event) => void | Promise<void>,
80772
+ * command?: string,
80773
+ * commandFor?: string,
80774
+ * prefetch?: boolean,
80478
80775
  * onValueChange?: (value: any, event: Event) => void,
80479
80776
  * chooseEffect?: "select" | "run",
80480
80777
  * menuLabel?: string,
@@ -80528,6 +80825,12 @@ const css$n = /* css */`.navi_split_button {
80528
80825
  * entry given. Pressed on the button it is the entry the button stands for;
80529
80826
  * with `chooseEffect="run"` it is also the entry just chosen. Awaited: the
80530
80827
  * whole split button is busy until it settles.
80828
+ * @param {string} [command] What a press on the button does once its `action`
80829
+ * is done, as on a `<Button>` (with `commandFor`, and `command-value` when
80830
+ * what the command is about is not the entry the button stands for). A
80831
+ * split button whose button opens a popup says it here rather than from its
80832
+ * `action`: the press is then read as an opening before it runs, and asks
80833
+ * ahead for what the popup's opening reads (see `prefetch` on Button).
80531
80834
  * @param {"select"|"run"} [chooseEffect="select"] What choosing an entry does.
80532
80835
  * "select" hands the button that entry, to be run by a press on it — for
80533
80836
  * something one does again and again, where the last choice is the likely
@@ -80569,6 +80872,10 @@ const SplitButton = props => {
80569
80872
  defaultValue,
80570
80873
  label,
80571
80874
  action,
80875
+ command,
80876
+ commandFor,
80877
+ "command-value": commandValue,
80878
+ prefetch,
80572
80879
  onValueChange,
80573
80880
  chooseEffect = "select",
80574
80881
  menuLabel = naviI18n("button.more_actions"),
@@ -80662,6 +80969,10 @@ const SplitButton = props => {
80662
80969
  id: idResolved,
80663
80970
  value: valueResolved,
80664
80971
  action: actionResolved,
80972
+ command: command,
80973
+ commandFor: commandFor,
80974
+ "command-value": commandValue,
80975
+ prefetch: prefetch,
80665
80976
  ...halfProps,
80666
80977
  children: label === undefined ? optionShown?.label : label
80667
80978
  }), jsxs("div", {