@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.
@@ -17751,6 +17751,33 @@ const syncOwnedResourceToSignals = (
17751
17751
 
17752
17752
  // Global signal registry for route template detection
17753
17753
  const globalSignalRegistry = new Map();
17754
+
17755
+ // The values some states are about to hold, answered in place of what they
17756
+ // hold for the length of one readStateAsIf.
17757
+ let valuesAsIf = null;
17758
+ /**
17759
+ * Runs `read` as if each state in `values` held the value given for it: the
17760
+ * address a press is about to write, read before it is written. Nothing is
17761
+ * written, so whoever watches those states hears nothing, and `read`
17762
+ * subscribes to nothing.
17763
+ *
17764
+ * Only a read of the state itself is answered this way. A `computed` derived
17765
+ * from one answers what it last computed — or, evaluated for the first time
17766
+ * inside `read`, caches a value derived from this answer: `read` reads the
17767
+ * states it depends on directly (a route action's params do).
17768
+ *
17769
+ * @param {Map<import("@preact/signals").Signal, any>} values
17770
+ * @param {() => any} read
17771
+ */
17772
+ const readStateAsIf = (values, read) => {
17773
+ const previous = valuesAsIf;
17774
+ valuesAsIf = values;
17775
+ try {
17776
+ return untracked(read);
17777
+ } finally {
17778
+ valuesAsIf = previous;
17779
+ }
17780
+ };
17754
17781
  let signalIdCounter = 0;
17755
17782
  const generateSignalId = () => {
17756
17783
  const id = signalIdCounter++;
@@ -18074,6 +18101,9 @@ const stateSignal = (defaultValue, options = {}) => {
18074
18101
  const valueDescriptor = Object.getOwnPropertyDescriptor(signalProto, "value");
18075
18102
  Object.defineProperty(preactSignal, "value", {
18076
18103
  get() {
18104
+ if (valuesAsIf !== null && valuesAsIf.has(preactSignal)) {
18105
+ return valuesAsIf.get(preactSignal);
18106
+ }
18077
18107
  return valueDescriptor.get.call(preactSignal);
18078
18108
  },
18079
18109
  set(newValue) {
@@ -26332,7 +26362,8 @@ const route = (
26332
26362
  };
26333
26363
  // Asks ahead of the arrival for what this route's actions ask without the
26334
26364
  // address — a page's code, a read without params (see routeAction). An
26335
- // action keyed by a param waits for the arrival: a prefetch has no address.
26365
+ // action keyed by a param waits for the arrival: a prefetch has no address
26366
+ // (preloadState is the one that has one).
26336
26367
  route.preload = () => {
26337
26368
  const preloadSet = routePreloadMap.get(route);
26338
26369
  if (!preloadSet) {
@@ -26899,6 +26930,40 @@ const preloadUrl = (url) => {
26899
26930
  }
26900
26931
  }
26901
26932
  };
26933
+
26934
+ const routeStatePreloadMap = new WeakMap();
26935
+ const registerRouteStatePreload = (route, preload) => {
26936
+ let preloadSet = routeStatePreloadMap.get(route);
26937
+ if (!preloadSet) {
26938
+ preloadSet = new Set();
26939
+ routeStatePreloadMap.set(route, preloadSet);
26940
+ }
26941
+ preloadSet.add(preload);
26942
+ };
26943
+ /**
26944
+ * Preloads what the routes one is on will ask for once `signal` holds
26945
+ * `value`: the address a popup's opening is about to write (see
26946
+ * open_controller.js), known at the press that opens it. Only the routes
26947
+ * matching now are asked — writing a state keeps one on the same page.
26948
+ */
26949
+ const preloadState = (signal, value) => {
26950
+ if (!activeRouteSet) {
26951
+ return;
26952
+ }
26953
+ const values = new Map([[signal, value]]);
26954
+ for (const route of activeRouteSet) {
26955
+ if (!route.matchingSignal.peek()) {
26956
+ continue;
26957
+ }
26958
+ const preloadSet = routeStatePreloadMap.get(route);
26959
+ if (!preloadSet) {
26960
+ continue;
26961
+ }
26962
+ for (const preload of preloadSet) {
26963
+ preload(values);
26964
+ }
26965
+ }
26966
+ };
26902
26967
  const setupRoutes = (routes) => {
26903
26968
  if (setupRoutesCalled) {
26904
26969
  throw new Error(
@@ -33880,10 +33945,14 @@ const writeCover = (edge, element, size) => {
33880
33945
  * the address, which is also what makes it worth fetching AHEAD of the
33881
33946
  * arrival: a link to the route preruns it when the pointer or the focus
33882
33947
  * reaches the link (see docs/dynamic_import.md). An action whose params
33883
- * come from the address is never prefetched — a prefetch has no address.
33948
+ * come from the address is not prefetched by a link — a link's prefetch has
33949
+ * no address. It is by the press that opens a popup bound to a state these
33950
+ * params read: that address is the current one with the state set to the
33951
+ * popup's value (see `prefetch` on Button).
33884
33952
  * @param {object} [options]
33885
- * @param {boolean} [options.prefetch=true] - `false` keeps a param-less action
33886
- * from being prerun on intent, when the read is not worth a hover.
33953
+ * @param {boolean} [options.prefetch=true] - `false` keeps the action from
33954
+ * being prerun ahead of what asks for it, when the read is not worth a
33955
+ * hover or a press that may not end in an opening.
33887
33956
  */
33888
33957
  const routeAction = (
33889
33958
  routeOrRoutes,
@@ -33912,27 +33981,47 @@ const routeAction = (
33912
33981
  },
33913
33982
  options,
33914
33983
  );
33915
- if (!paramsEffect && prefetch) {
33984
+ if (prefetch) {
33916
33985
  for (const route of routes) {
33917
- registerRoutePreload(route, () => {
33918
- prefetchParamless(action);
33919
- });
33986
+ if (paramsEffect) {
33987
+ registerRouteStatePreload(route, (values) => {
33988
+ prefetchAsIf(action, paramsEffect, values);
33989
+ });
33990
+ } else {
33991
+ registerRoutePreload(route, () => {
33992
+ prerunAhead(action.bindParams(true));
33993
+ });
33994
+ }
33920
33995
  }
33921
33996
  }
33922
33997
  return actionBoundToRoute;
33923
33998
  };
33924
33999
 
33925
- // The instance the effect above runs on the match is the one bound to `true`
33926
- // (the default params), so a prerun here is what the arrival promotes to a
33927
- // run. A prefetch that fails is not reported: nothing on screen asked for it,
33928
- // and the failure stays on the instance, where a run asks again — a FAILED
33929
- // action is run, only RUNNING and COMPLETED are left alone — and the arrival
33930
- // shows its own. It is left FAILED on purpose, not reset: the press that
33931
- // brings the arrival also focuses the link, so a prerun can be in flight when
33932
- // the arrival promotes it, and a reset landing after that would pull the
33933
- // answer from under the page that is reading it.
33934
- const prefetchParamless = (action) => {
33935
- const instance = action.bindParams(true);
34000
+ // The params as they will read once the states hold what they are about to
34001
+ // (see readStateAsIf), prerun only when that changes them: params the state
34002
+ // leaves alone are the ones the action already runs on — or is meant not to,
34003
+ // and a prerun would retry a FAILED one behind the screen's back.
34004
+ const prefetchAsIf = (action, paramsEffect, values) => {
34005
+ const params = readStateAsIf(values, paramsEffect);
34006
+ if (!params) {
34007
+ return;
34008
+ }
34009
+ if (compareTwoJsValues(params, untracked(paramsEffect))) {
34010
+ return;
34011
+ }
34012
+ prerunAhead(action.bindParams(params));
34013
+ };
34014
+
34015
+ // The instance prerun is the one the arrival runs on — the one bound to the
34016
+ // params it reads (`true` for a param-less action) — so the arrival promotes
34017
+ // it to a run. A prefetch that fails is not reported: nothing on screen asked
34018
+ // for it, and the failure stays on the instance, where a run asks again — a
34019
+ // FAILED action is run, only RUNNING and COMPLETED are left alone — and the
34020
+ // arrival shows its own. It is left FAILED on purpose, not reset: a prerun is
34021
+ // often still in flight when the arrival promotes it (the press that brings
34022
+ // the arrival focuses the link, or is the one that asked), and a reset
34023
+ // landing after that would pull the answer from under the page reading it.
34024
+ const prerunAhead = (instance) => {
33936
34025
  let result;
33937
34026
  try {
33938
34027
  result = instance.prerun({ reason: "intent" });
@@ -34930,6 +35019,30 @@ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
34930
35019
  },
34931
35020
  };
34932
35021
  });
35022
+ /**
35023
+ * Tells the popup a source opens that a press on it has started, ahead of the
35024
+ * release that runs the command: what the opening will read can be asked for
35025
+ * now (see open_controller.js). A press rather than a hover, because what
35026
+ * that reads is keyed by the popup — one sheet per card — and a pointer
35027
+ * crossing a list of cards has chosen none of them; on a touch screen the
35028
+ * press is the first thing the finger says anyway.
35029
+ *
35030
+ * The target is found the way --navi-open finds it, but silently: nothing
35031
+ * runs here, and a source with no target says so at the release.
35032
+ */
35033
+ const announceOpeningPress = (source, command) => {
35034
+ if (command !== "--navi-open" && command !== "--navi-toggle") {
35035
+ return;
35036
+ }
35037
+ const commandFor = source.getAttribute("commandfor");
35038
+ const target = commandFor
35039
+ ? document.getElementById(commandFor)
35040
+ : resolveExplicitTarget(source) || resolveClosestExpandable(source);
35041
+ if (!target) {
35042
+ return;
35043
+ }
35044
+ dispatchCustomEvent(target, "navi_open_press");
35045
+ };
34933
35046
  // "--navi-close:all" closes every expandable above the source, nearest first —
34934
35047
  // a link leaving from a badge shown over a sheet leaves both. A surface that
34935
35048
  // refuses (a form asking about its changes) keeps what is above it open too:
@@ -36383,7 +36496,7 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
36383
36496
  return;
36384
36497
  }
36385
36498
  if (opened) {
36386
- signal.value = popupValue === undefined ? true : popupValue;
36499
+ signal.value = readOpenValue(popupValue);
36387
36500
  return;
36388
36501
  }
36389
36502
  const closedValue = readClosedValue(signalValue);
@@ -36415,6 +36528,9 @@ const readOpened = (signalValue, popupValue) => {
36415
36528
  }
36416
36529
  return signalValue === popupValue;
36417
36530
  };
36531
+ const readOpenValue = (popupValue) => {
36532
+ return popupValue === undefined ? true : popupValue;
36533
+ };
36418
36534
  const readClosedValue = (openSignalValue) => {
36419
36535
  return openSignalValue === true ? false : undefined;
36420
36536
  };
@@ -36488,6 +36604,29 @@ const useOpenPropsEffectOnOpenController = (
36488
36604
  }
36489
36605
  }
36490
36606
  : null;
36607
+ // A press that will open this popup has started (see announceOpeningPress
36608
+ // in commands.js). What the opening writes — the popup's value, in its
36609
+ // signal — is known before the release, so what the screen reads off it is
36610
+ // asked for now (see preloadState).
36611
+ useEffect(() => {
36612
+ if (!signal) {
36613
+ return undefined;
36614
+ }
36615
+ const element = openController.getElement?.();
36616
+ if (!element) {
36617
+ return undefined;
36618
+ }
36619
+ const onOpenPress = () => {
36620
+ if (openController.opened) {
36621
+ return;
36622
+ }
36623
+ preloadState(signal, readOpenValue(value));
36624
+ };
36625
+ element.addEventListener("navi_open_press", onOpenPress);
36626
+ return () => {
36627
+ element.removeEventListener("navi_open_press", onOpenPress);
36628
+ };
36629
+ }, [signal, value]);
36491
36630
  // Tracks whether the effect below has ever run before — only the very
36492
36631
  // first run gets the "mount already open" treatment (`open` truthy from
36493
36632
  // the start, or the uncontrolled, mount-only `defaultOpen`); every
@@ -37182,17 +37321,30 @@ const armOutsidePressClose = (
37182
37321
  * content ahead of the click that will open it. Deferring the build to the
37183
37322
  * opening puts its whole cost in the frame right after the click — the frame
37184
37323
  * where a delay is felt hardest — while the ~100-300ms between hovering a
37185
- * trigger and pressing it are free. The warming render is asynchronous
37186
- * (batched, not flushed): nothing here needs the content in the DOM before
37187
- * the click, only before the open that follows it.
37188
- *
37189
- * "while-opened" content is never warmed. That mode promises two things
37190
- * warming would break: the content is built at open time (so a `defaultValue`
37191
- * read at build time is fresh, not seeded at pointer-enter time), and it is
37192
- * only ever mounted between an open and a close — unmounting happens on close,
37193
- * so a warmed popup that never opens would keep its content in the document
37194
- * indefinitely. Callers lean on that guarantee (e.g. several pickers sharing
37195
- * one set of content ids because only one content exists at a time).
37324
+ * trigger and pressing it are free. The warming render starts at the frame
37325
+ * after the one that follows the intent: nothing here needs the content in the
37326
+ * DOM before the click, only before the open that follows it, and on a touch
37327
+ * screen `pointerenter` arrives together with the `pointerdown` — a build
37328
+ * started there would hold back the frame that shows the press. Two animation
37329
+ * frames, never a timeout: browsers hold timers back while a finger is down,
37330
+ * and a timeout started by the press fires at its release, on top of the click.
37331
+ *
37332
+ * "while-opened" content is warmed by a press on the anchor, never by a hover
37333
+ * or a focus. That mode promises content built fresh for the gesture that
37334
+ * opens it, and mounted only while that gesture and its opening last: callers
37335
+ * lean on it (several pickers sharing one set of content ids, because only one
37336
+ * content exists at a time). A pointer crossing four anchors would build four
37337
+ * contents; a press is the start of one opening. So the press builds it, and
37338
+ * the press ending without an open throws it away — its click reaching the
37339
+ * document with the popup still closed, no click coming after the release,
37340
+ * the browser taking the gesture (`pointercancel`), or the next press
37341
+ * starting, before any of its own handlers run: one press at a time holds a
37342
+ * content. A keyboard opening
37343
+ * builds at open time, as without warming. What this gives up: a press-warmed
37344
+ * content is built before `onOpen` runs. The popups seeding their content from
37345
+ * `onOpen` are opened ON something by a command, whose press is not on their
37346
+ * anchor, so they are not warmed; one pressed on its own anchor that still
37347
+ * seeds from `onOpen` reads the value from before it.
37196
37348
  */
37197
37349
 
37198
37350
 
@@ -37234,26 +37386,35 @@ const usePopupContentMount = (
37234
37386
  { mount = MOUNT_DEFAULT, anchor },
37235
37387
  ) => {
37236
37388
  const mountedAlways = mount === "always";
37237
- const [contentMounted, setContentMounted] = useState(
37389
+ const [contentMounted, setContentMountedState] = useState(
37238
37390
  () => mountedAlways || openController.opened,
37239
37391
  );
37240
- openController.mountContent = contentMounted
37241
- ? null
37242
- : () => {
37243
- const popupElement = ref?.current;
37244
- if (popupElement) {
37245
- popupsMountingContentForOpen.add(popupElement);
37246
- }
37247
- try {
37248
- flushSyncRendering(() => {
37249
- setContentMounted(true);
37250
- });
37251
- } finally {
37252
- if (popupElement) {
37253
- popupsMountingContentForOpen.delete(popupElement);
37254
- }
37255
- }
37256
- };
37392
+ // What was last asked for, ahead of the render that performs it: an open
37393
+ // landing in between (a press-warmed content given up just before the open
37394
+ // that wanted it) must still find it has to build.
37395
+ const contentMountedRef = useRef(contentMounted);
37396
+ const setContentMounted = (value) => {
37397
+ contentMountedRef.current = value;
37398
+ setContentMountedState(value);
37399
+ };
37400
+ openController.mountContent = () => {
37401
+ if (contentMountedRef.current) {
37402
+ return;
37403
+ }
37404
+ const popupElement = ref?.current;
37405
+ if (popupElement) {
37406
+ popupsMountingContentForOpen.add(popupElement);
37407
+ }
37408
+ try {
37409
+ flushSyncRendering(() => {
37410
+ setContentMounted(true);
37411
+ });
37412
+ } finally {
37413
+ if (popupElement) {
37414
+ popupsMountingContentForOpen.delete(popupElement);
37415
+ }
37416
+ }
37417
+ };
37257
37418
  openController.unmountContent =
37258
37419
  mount === "while-opened"
37259
37420
  ? () => {
@@ -37291,41 +37452,140 @@ const usePopupContentMount = (
37291
37452
  cancelIdle(idleId);
37292
37453
  };
37293
37454
  }, [mount, contentMounted]);
37294
- // Warm on intent (see the top comment; "while-opened" is excluded there).
37295
- // The anchor accepts the same shapes Popover resolves at open time — a
37296
- // string id, a ref, an element — but is resolved here at effect time: an id
37297
- // that matches nothing yet simply doesn't warm, the open still mounts the
37298
- // content like it always does.
37455
+ // Warm on intent (see the top comment).
37299
37456
  useEffect(() => {
37300
37457
  if (contentMounted || !anchor || mount === "while-opened") {
37301
37458
  return undefined;
37302
37459
  }
37303
- const anchorElement =
37304
- typeof anchor === "string"
37305
- ? document.getElementById(anchor)
37306
- : // A ref is unwrapped even when it holds nothing: an expandable with
37307
- // no UI part hands an empty ref over, and the ref object itself is
37308
- // truthy — it would reach addEventListener below and throw.
37309
- "current" in anchor
37310
- ? anchor.current
37311
- : anchor;
37460
+ const anchorElement = resolveAnchorElement(anchor);
37312
37461
  if (!anchorElement) {
37313
37462
  return undefined;
37314
37463
  }
37464
+ let cancelWarm = null;
37315
37465
  const warm = () => {
37316
- setContentMounted(true);
37466
+ if (cancelWarm) {
37467
+ return;
37468
+ }
37469
+ cancelWarm = requestFrameAfterNext(() => {
37470
+ setContentMounted(true);
37471
+ });
37317
37472
  };
37318
37473
  anchorElement.addEventListener("pointerenter", warm);
37319
37474
  anchorElement.addEventListener("focusin", warm);
37320
37475
  return () => {
37476
+ cancelWarm?.();
37321
37477
  anchorElement.removeEventListener("pointerenter", warm);
37322
37478
  anchorElement.removeEventListener("focusin", warm);
37323
37479
  };
37324
37480
  }, [contentMounted, anchor, mount]);
37481
+ // Warm on the press, for "while-opened" (see the top comment). Listening for
37482
+ // as long as the anchor is there, not only while the content is unmounted:
37483
+ // the press that throws a warmed content away can be a new press on this
37484
+ // same anchor, and it must warm again.
37485
+ useEffect(() => {
37486
+ if (!anchor || mount !== "while-opened") {
37487
+ return undefined;
37488
+ }
37489
+ const anchorElement = resolveAnchorElement(anchor);
37490
+ if (!anchorElement) {
37491
+ return undefined;
37492
+ }
37493
+ let stopPressWarm = null;
37494
+ const onPointerDown = (pointerdownEvent) => {
37495
+ if (pointerdownEvent.button !== 0 || contentMountedRef.current) {
37496
+ return;
37497
+ }
37498
+ const cancelBuild = requestFrameAfterNext(() => {
37499
+ setContentMounted(true);
37500
+ });
37501
+ const stopWatching = watchPressEnd(() => {
37502
+ stopPressWarm = null;
37503
+ cancelBuild();
37504
+ if (!openController.opened) {
37505
+ setContentMounted(false);
37506
+ }
37507
+ });
37508
+ stopPressWarm = () => {
37509
+ cancelBuild();
37510
+ stopWatching();
37511
+ };
37512
+ };
37513
+ anchorElement.addEventListener("pointerdown", onPointerDown);
37514
+ return () => {
37515
+ stopPressWarm?.();
37516
+ anchorElement.removeEventListener("pointerdown", onPointerDown);
37517
+ };
37518
+ }, [anchor, mount]);
37325
37519
 
37326
37520
  return contentMounted;
37327
37521
  };
37328
37522
 
37523
+ // The anchor accepts the same shapes Popover resolves at open time — a string
37524
+ // id, a ref, an element — but is resolved at effect time: an id that matches
37525
+ // nothing yet simply doesn't warm, the open still mounts the content.
37526
+ const resolveAnchorElement = (anchor) => {
37527
+ if (typeof anchor === "string") {
37528
+ return document.getElementById(anchor);
37529
+ }
37530
+ // A ref is unwrapped even when it holds nothing: an expandable with no UI
37531
+ // part hands an empty ref over, and the ref object itself is truthy — it
37532
+ // would reach addEventListener and throw.
37533
+ if ("current" in anchor) {
37534
+ return anchor.current;
37535
+ }
37536
+ return anchor;
37537
+ };
37538
+
37539
+ // The next frame paints what the input just changed (the pressed trigger); the
37540
+ // callback runs at the start of the one after.
37541
+ const requestFrameAfterNext = (callback) => {
37542
+ let frame = requestAnimationFrame(() => {
37543
+ frame = requestAnimationFrame(callback);
37544
+ });
37545
+ return () => {
37546
+ cancelAnimationFrame(frame);
37547
+ };
37548
+ };
37549
+
37550
+ // How long the click of a released press may take to arrive. Nothing tells a
37551
+ // page that a release brings no click — a press that became a drag has its
37552
+ // click swallowed (suppressClickAfterGesture in @jsenv/dom) — so past this the
37553
+ // press is taken to have ended without one. Longer than the 300ms a browser
37554
+ // still waits before the click of a tap on a page not sized for phones.
37555
+ const PRESS_CLICK_WAIT_MS = 500;
37556
+
37557
+ // Installed during the press's own pointerdown: the capture listeners below
37558
+ // only hear the presses after it.
37559
+ const watchPressEnd = (onEnd) => {
37560
+ let timeout;
37561
+ const stop = () => {
37562
+ clearTimeout(timeout);
37563
+ document.removeEventListener("click", onClick);
37564
+ document.removeEventListener("pointerup", onPointerUp, { capture: true });
37565
+ document.removeEventListener("pointercancel", end, { capture: true });
37566
+ document.removeEventListener("pointerdown", end, { capture: true });
37567
+ };
37568
+ const end = () => {
37569
+ stop();
37570
+ onEnd();
37571
+ };
37572
+ // Bubble phase, on the document: every click handler that could open the
37573
+ // popup has run by then.
37574
+ const onClick = (clickEvent) => {
37575
+ if (isPressDrivenClick(clickEvent)) {
37576
+ end();
37577
+ }
37578
+ };
37579
+ const onPointerUp = () => {
37580
+ timeout = setTimeout(end, PRESS_CLICK_WAIT_MS);
37581
+ };
37582
+ document.addEventListener("click", onClick);
37583
+ document.addEventListener("pointerup", onPointerUp, { capture: true });
37584
+ document.addEventListener("pointercancel", end, { capture: true });
37585
+ document.addEventListener("pointerdown", end, { capture: true });
37586
+ return stop;
37587
+ };
37588
+
37329
37589
  /**
37330
37590
  * A variant of useLayoutEffect that accounts for ancestor <dialog>/<details>
37331
37591
  * or popover visibility.
@@ -45965,6 +46225,31 @@ const usePreloadOnIntent = (ref, href, prefetch = true) => {
45965
46225
  }, [ref, href, prefetch]);
45966
46226
  };
45967
46227
 
46228
+ /**
46229
+ * What a popup's opening will read, asked for when the press on the element
46230
+ * whose command opens it starts (see announceOpeningPress): the popup, the
46231
+ * value it opens on and so the address it will write are all known before
46232
+ * the release.
46233
+ */
46234
+ const usePreloadOpeningOnPress = (ref, command, prefetch = true) => {
46235
+ useEffect(() => {
46236
+ if (!prefetch || !command) {
46237
+ return undefined;
46238
+ }
46239
+ const element = ref.current;
46240
+ if (!element) {
46241
+ return undefined;
46242
+ }
46243
+ const onPointerDown = () => {
46244
+ announceOpeningPress(element, command);
46245
+ };
46246
+ element.addEventListener("pointerdown", onPointerDown);
46247
+ return () => {
46248
+ element.removeEventListener("pointerdown", onPointerDown);
46249
+ };
46250
+ }, [ref, command, prefetch]);
46251
+ };
46252
+
45968
46253
  /**
45969
46254
  * Hook that reactively checks if a URL is visited.
45970
46255
  * Re-renders when the visited URL set changes.
@@ -49965,7 +50250,8 @@ const useExpandableContext = partName => {
49965
50250
  * content — and rebuilds it from scratch on every expansion. Under the
49966
50251
  * other three, intent on the UI part (pointer entering it, focus landing in
49967
50252
  * it) builds the content ahead of the click; `"while-opened"` content is
49968
- * never warmed, it is built at the open.
50253
+ * built by a press on it only, and thrown away if that press does not
50254
+ * expand it.
49969
50255
  */
49970
50256
  const Expandable = props => {
49971
50257
  import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -51277,6 +51563,9 @@ const ButtonUI = props => {
51277
51563
  // Nothing to prefetch on the way to another document: the routes do not
51278
51564
  // lead there.
51279
51565
  usePreloadOnIntent(ref, href, isDocument ? false : prefetch);
51566
+ // A press the button refuses opens nothing: nothing to ask ahead for.
51567
+ const refusesPress = basePseudoState[":read-only"] || basePseudoState[":disabled"] || loading;
51568
+ usePreloadOpeningOnPress(ref, props.command, prefetch && !refusesPress);
51280
51569
  useAccentColorAttributes(ref, null, {
51281
51570
  elementSelector: visualSelector
51282
51571
  });
@@ -51768,8 +52057,11 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
51768
52057
  * pointer or the focus arrives, ahead of the press (see
51769
52058
  * docs/dynamic_import.md): the route actions that ask nothing of the
51770
52059
  * address — a page's code, a read without params — are prerun; one keyed by
51771
- * a param waits for the arrival. `false` for a destination not worth
51772
- * fetching on a hover.
52060
+ * a param waits for the arrival. With a `--navi-open`/`--navi-toggle`
52061
+ * command instead, the press — not the hover: what it asks is keyed by the
52062
+ * popup — preruns the route actions whose params the popup's opening
52063
+ * changes, read as if its `signal` held its `value`. `false` for a
52064
+ * destination not worth fetching ahead.
51773
52065
  * @param {boolean} [pressableDuringRouteTransition] Keep answering presses
51774
52066
  * while a route transition plays: what a movement photographs goes deaf to
51775
52067
  * the pointer for its whole length, and the door that opened the page — a
@@ -62537,9 +62829,10 @@ const css$E = /* css */`
62537
62829
  * the cost on the critical render. `"while-opened"` throws them away once
62538
62830
  * the popup has finished closing, for content whose fresh state is its
62539
62831
  * initial state: an uncontrolled field seeded from a `defaultValue` that
62540
- * changed while the popup was closed. Whatever the value, intent on the
62541
- * anchor (pointer entering it, focus landing in it) builds the content
62542
- * ahead of the click.
62832
+ * changed while the popup was closed. Intent on the anchor (pointer
62833
+ * entering it, focus landing in it) builds the content ahead of the click;
62834
+ * under `"while-opened"`, only a press on it does, and a press that does not
62835
+ * open the popup throws it away.
62543
62836
  * @param {import("ignore:preact").ComponentChildren} props.children
62544
62837
  */
62545
62838
  const Dialog = props => {
@@ -64217,9 +64510,10 @@ const css$D = /* css */`
64217
64510
  * the cost on the critical render. `"while-opened"` throws them away once
64218
64511
  * the popup has finished closing, for content whose fresh state is its
64219
64512
  * initial state: an uncontrolled field seeded from a `defaultValue` that
64220
- * changed while the popup was closed. Whatever the value, intent on the
64221
- * anchor (pointer entering it, focus landing in it) builds the content
64222
- * ahead of the click.
64513
+ * changed while the popup was closed. Intent on the anchor (pointer
64514
+ * entering it, focus landing in it) builds the content ahead of the click;
64515
+ * under `"while-opened"`, only a press on it does, and a press that does not
64516
+ * open the popup throws it away.
64223
64517
  * @param {import("ignore:preact").ComponentChildren} props.children
64224
64518
  */
64225
64519
  const Popover = props => {
@@ -78354,6 +78648,9 @@ const css$n = /* css */`.navi_split_button {
78354
78648
  * defaultValue?: any,
78355
78649
  * label?: import("ignore:preact").ComponentChildren,
78356
78650
  * action?: (value: any, event: Event) => void | Promise<void>,
78651
+ * command?: string,
78652
+ * commandFor?: string,
78653
+ * prefetch?: boolean,
78357
78654
  * onValueChange?: (value: any, event: Event) => void,
78358
78655
  * chooseEffect?: "select" | "run",
78359
78656
  * menuLabel?: string,
@@ -78407,6 +78704,12 @@ const css$n = /* css */`.navi_split_button {
78407
78704
  * entry given. Pressed on the button it is the entry the button stands for;
78408
78705
  * with `chooseEffect="run"` it is also the entry just chosen. Awaited: the
78409
78706
  * whole split button is busy until it settles.
78707
+ * @param {string} [command] What a press on the button does once its `action`
78708
+ * is done, as on a `<Button>` (with `commandFor`, and `command-value` when
78709
+ * what the command is about is not the entry the button stands for). A
78710
+ * split button whose button opens a popup says it here rather than from its
78711
+ * `action`: the press is then read as an opening before it runs, and asks
78712
+ * ahead for what the popup's opening reads (see `prefetch` on Button).
78410
78713
  * @param {"select"|"run"} [chooseEffect="select"] What choosing an entry does.
78411
78714
  * "select" hands the button that entry, to be run by a press on it — for
78412
78715
  * something one does again and again, where the last choice is the likely
@@ -78448,6 +78751,10 @@ const SplitButton = props => {
78448
78751
  defaultValue,
78449
78752
  label,
78450
78753
  action,
78754
+ command,
78755
+ commandFor,
78756
+ "command-value": commandValue,
78757
+ prefetch,
78451
78758
  onValueChange,
78452
78759
  chooseEffect = "select",
78453
78760
  menuLabel = naviI18n("button.more_actions"),
@@ -78541,6 +78848,10 @@ const SplitButton = props => {
78541
78848
  id: idResolved,
78542
78849
  value: valueResolved,
78543
78850
  action: actionResolved,
78851
+ command: command,
78852
+ commandFor: commandFor,
78853
+ "command-value": commandValue,
78854
+ prefetch: prefetch,
78544
78855
  ...halfProps,
78545
78856
  children: label === undefined ? optionShown?.label : label
78546
78857
  }), jsxs("div", {