@jsenv/navi 0.29.458 → 0.29.460

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.
@@ -1927,6 +1927,27 @@ const markErrorAsTakenByRender = (error) => {
1927
1927
  }
1928
1928
  };
1929
1929
 
1930
+ /**
1931
+ * A prerun's failure is owed to no screen: nothing on screen asked for that
1932
+ * run, so the component that owns the read asks again rather than show a
1933
+ * failure from before it existed (see `run` in use_async_data.jsx). Taken
1934
+ * once, so the run asking again fails as its own even when it fails with the
1935
+ * same error object. Held by the error, it lasts as long as the failure stays
1936
+ * on the action: a later run replaces it, whether or not an owner ever came.
1937
+ */
1938
+ const prerunFailureSet = new WeakSet();
1939
+ const markErrorAsPrerunFailure = (error) => {
1940
+ if (error && typeof error === "object") {
1941
+ prerunFailureSet.add(error);
1942
+ }
1943
+ };
1944
+ const takePrerunFailure = (error) => {
1945
+ if (!error || typeof error !== "object") {
1946
+ return false;
1947
+ }
1948
+ return prerunFailureSet.delete(error);
1949
+ };
1950
+
1930
1951
  /**
1931
1952
  * When the answer "nobody took it" is final.
1932
1953
  *
@@ -4352,6 +4373,9 @@ const createAction = (callback, rootOptions = {}) => {
4352
4373
  console.log(`"${action}": failed (error: ${error})`);
4353
4374
  }
4354
4375
  error.action = action;
4376
+ if (isPrerun) {
4377
+ markErrorAsPrerunFailure(error);
4378
+ }
4355
4379
  batch(() => {
4356
4380
  errorSignal.value = error;
4357
4381
  runningStateSignal.value = FAILED;
@@ -4928,6 +4952,25 @@ const runUnwatched = (startRun) => {
4928
4952
  return result;
4929
4953
  };
4930
4954
 
4955
+ /**
4956
+ * Starts a prerun navi has nobody to await. Its failure is not reported:
4957
+ * nothing on screen asked for it. It stays on the action, marked as a
4958
+ * prerun's (see markErrorAsPrerunFailure), where whatever owns the read asks
4959
+ * again.
4960
+ */
4961
+ const prerunUnwatched = (startPrerun) => {
4962
+ let result;
4963
+ try {
4964
+ result = startPrerun();
4965
+ } catch {
4966
+ return undefined;
4967
+ }
4968
+ if (result && typeof result.catch === "function") {
4969
+ result.catch(() => {});
4970
+ }
4971
+ return result;
4972
+ };
4973
+
4931
4974
  /**
4932
4975
  * Reactively runs an action whenever the params derived from signals change.
4933
4976
  *
@@ -11310,27 +11353,29 @@ const updateDocumentState = (value) => {
11310
11353
  * `state`:
11311
11354
  * - `undefined` — a neutral navigation: a link click, a `replaceUrl` writing a
11312
11355
  * search param. A **replace** stays on the entry the document is already on,
11313
- * so what that entry holds stays with it. A **push** opens a NEW entry, and
11314
- * what was written for the one being left — an open dialog, an expanded
11315
- * picker (see `useNavState`) — describes that entry alone: carried forward it
11316
- * would reopen on the next screen, and on the one after that, until something
11317
- * mounting the same id opens out of nowhere.
11356
+ * so what that entry holds stays with it. A **push** opens a NEW entry. One
11357
+ * arriving at another page (`isArrival`, see scroll_restoration.js) starts
11358
+ * empty: what was written for the entry being left — an open dialog, an
11359
+ * expanded picker (see `useNavState`) — describes that entry alone, and
11360
+ * carried forward it would reopen on the next screen, and on the one after
11361
+ * that, until something mounting the same id opens out of nowhere. A push
11362
+ * that keeps the page (a layer bound to a search param, opened over it)
11363
+ * carries the state: whatever holds it is still mounted under the new
11364
+ * address, and an empty entry would close it there — an expanded row
11365
+ * collapsing under the layer, expanding again when the back takes it away.
11318
11366
  * - `null` — an explicit reset.
11319
11367
  * - an object — built by the caller (`enter()`/`leave()` copy the current state
11320
11368
  * themselves), taken as given.
11321
11369
  *
11322
- * `sharedState` always wins: it describes the document, not the entry.
11323
- *
11324
- * The push/replace split is the Navigation API's own rule for a `navigate()`
11325
- * carrying no state — which is why via_navigation.js gets it from the browser
11326
- * and via_history.js has to spell it out to say the same thing.
11370
+ * `currentState` is what the entry being left holds. `sharedState` always
11371
+ * wins: it describes the document, not the entry.
11327
11372
  */
11328
11373
  const resolveEffectiveDocumentState = (
11329
11374
  state,
11330
- { navigationType, currentState, sharedState },
11375
+ { navigationType, isArrival, currentState, sharedState },
11331
11376
  ) => {
11332
11377
  if (state === undefined) {
11333
- if (navigationType === "push") {
11378
+ if (navigationType === "push" && isArrival) {
11334
11379
  return sharedState;
11335
11380
  }
11336
11381
  return { ...(currentState || {}), ...sharedState };
@@ -11874,6 +11919,8 @@ const startAtTop = (url, { from } = {}) => {
11874
11919
  positionByUrl.set(href, { x: 0, y: 0 });
11875
11920
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
11876
11921
  };
11922
+ // Also what decides whether a push carries the state of the page it keeps
11923
+ // (see resolveEffectiveDocumentState).
11877
11924
  const isArrival = (url, { from }) => {
11878
11925
  if (
11879
11926
  from !== undefined &&
@@ -12309,7 +12356,8 @@ const setupBrowserIntegrationViaHistory = ({
12309
12356
 
12310
12357
  const applyRoutingTask = (url, options) => {
12311
12358
  // Read before the history is written: the url the reader is being taken
12312
- // away from, which is what decides where a push lands (see startAtTop).
12359
+ // away from, which is what decides where a push lands (see startAtTop) and
12360
+ // whether it carries the state (see resolveEffectiveDocumentState).
12313
12361
  const urlLeft = readAddress();
12314
12362
  const {
12315
12363
  reason,
@@ -12329,6 +12377,7 @@ const setupBrowserIntegrationViaHistory = ({
12329
12377
  markUrlAsVisited(url);
12330
12378
  const effectiveState = resolveEffectiveDocumentState(state, {
12331
12379
  navigationType,
12380
+ isArrival: isArrival(url, { from: urlLeft }),
12332
12381
  currentState: getDocumentState(),
12333
12382
  sharedState: {
12334
12383
  jsenv_visited_urls: Array.from(visitedUrlSet),
@@ -26464,10 +26513,24 @@ const resolveRerunOn = (rerunOn, inheritedRerunOn = defaultRerunOn) => {
26464
26513
  };
26465
26514
  };
26466
26515
 
26516
+ // What a `dependencies` entry names are callbacks, not verbs (`rerunOn` names
26517
+ // verbs, where `PATCH` covers `PATCH_MANY`): one callback of a resource can be
26518
+ // the write that creates (a move into a new item) while its sibling only edits.
26519
+ const WRITE_CALLBACK_NAMES = [
26520
+ "POST",
26521
+ "POST_MANY",
26522
+ "PUT",
26523
+ "PUT_MANY",
26524
+ "PATCH",
26525
+ "PATCH_MANY",
26526
+ "DELETE",
26527
+ "DELETE_MANY",
26528
+ ];
26529
+
26467
26530
  // This handles ALL resource lifecycle logic (rerun/reset) across all resources
26468
26531
  const createResourceLifecycleManager = () => {
26469
26532
  const registeredResources = new Map(); // Map<resourceInstance, lifecycleConfig>
26470
- const resourceDependencies = new Map(); // Map<resourceInstance, Set<dependentResources>> — user-configured
26533
+ const resourceDependencies = new Map(); // Map<resourceInstance, Map<dependentResource, Set<writeCallbackName>>> — user-configured
26471
26534
  const scopedManyParents = new Map(); // Map<childResource, Set<{resource, propertyName}>> — auto from scopedMany
26472
26535
 
26473
26536
  const registerResource = (resourceScope, config) => {
@@ -26486,16 +26549,38 @@ const createResourceLifecycleManager = () => {
26486
26549
  rangeReaderSet: new Set(),
26487
26550
  });
26488
26551
 
26489
- // Register dependencies
26490
- if (dependencies.length > 0) {
26491
- for (const dependency of dependencies) {
26492
- if (!resourceDependencies.has(dependency)) {
26493
- resourceDependencies.set(dependency, new Set());
26494
- }
26495
- resourceDependencies.get(dependency).add(resourceScope);
26552
+ for (const dependency of dependencies) {
26553
+ const [dependencyScope, writeNames] = resolveDependency(
26554
+ dependency,
26555
+ resourceScope,
26556
+ );
26557
+ let dependentMap = resourceDependencies.get(dependencyScope);
26558
+ if (!dependentMap) {
26559
+ dependentMap = new Map();
26560
+ resourceDependencies.set(dependencyScope, dependentMap);
26561
+ }
26562
+ let writeNameSet = dependentMap.get(resourceScope);
26563
+ if (!writeNameSet) {
26564
+ writeNameSet = new Set();
26565
+ dependentMap.set(resourceScope, writeNameSet);
26566
+ }
26567
+ for (const writeName of writeNames) {
26568
+ writeNameSet.add(writeName);
26496
26569
  }
26497
26570
  }
26498
26571
  };
26572
+ // Whether `resourceScope` listed the facade that wrote, with this write
26573
+ // among the ones that count.
26574
+ const isWriteHeardBy = (resourceScope, triggeringAction, triggerScope) => {
26575
+ const writeNameSet = resourceDependencies
26576
+ .get(triggerScope)
26577
+ ?.get(resourceScope);
26578
+ if (!writeNameSet) {
26579
+ return false;
26580
+ }
26581
+ const { verb, isMany } = triggeringAction.meta;
26582
+ return writeNameSet.has(isMany ? `${verb}_MANY` : verb);
26583
+ };
26499
26584
  // Only the reader the resource exposes is registered; the ones `bindParams`
26500
26585
  // makes share its signal (see resource_range_reader.js).
26501
26586
  const registerRangeReader = (resourceScope, rangeReader) => {
@@ -26536,17 +26621,18 @@ const createResourceLifecycleManager = () => {
26536
26621
  triggerVerb === "PATCH") &&
26537
26622
  config.uniqueKeys.length > 0;
26538
26623
 
26539
- const isKnownDependency =
26540
- triggerResourceScope !== null &&
26541
- triggerResourceScope !== undefined &&
26542
- resourceDependencies.get(triggerResourceScope)?.has(resourceScope);
26624
+ const isHeardDependency = isWriteHeardBy(
26625
+ resourceScope,
26626
+ triggeringAction,
26627
+ triggerResourceScope,
26628
+ );
26543
26629
 
26544
26630
  if (
26545
26631
  !shouldRerunGetMany &&
26546
26632
  !shouldRerunGet &&
26547
26633
  triggerVerb !== "DELETE" &&
26548
26634
  !hasUniqueKeyAutorerun &&
26549
- !isKnownDependency
26635
+ !isHeardDependency
26550
26636
  ) {
26551
26637
  continue;
26552
26638
  }
@@ -26667,21 +26753,10 @@ const createResourceLifecycleManager = () => {
26667
26753
  }
26668
26754
 
26669
26755
  // Cross-resource dependency effects: rerun dependent GET / GET_MANY
26670
- // Fires on any mutating verb — user-configured dependencies express
26671
- // "this resource depends on another resource's data", so any mutation
26672
- // (POST, PUT, PATCH, DELETE) on the dependency should trigger a rerun.
26756
+ // after a write the dependency counts — every write for a resource
26757
+ // listed bare, the callbacks named in `on` otherwise.
26673
26758
  {
26674
- if (
26675
- triggerResourceScope &&
26676
- resourceDependencies
26677
- .get(triggerResourceScope)
26678
- ?.has(resourceScope) &&
26679
- (triggerVerb === "POST" ||
26680
- triggerVerb === "PUT" ||
26681
- triggerVerb === "PATCH" ||
26682
- triggerVerb === "DELETE") &&
26683
- candidateVerb === "GET"
26684
- ) {
26759
+ if (isHeardDependency && candidateVerb === "GET") {
26685
26760
  actionsToRerun.add(actionCandidate);
26686
26761
  reasonSet.add("dependency autorerun");
26687
26762
  continue;
@@ -26758,9 +26833,10 @@ const createResourceLifecycleManager = () => {
26758
26833
  continue;
26759
26834
  }
26760
26835
  const isSameResource = resourceScope === triggerResourceScope;
26761
- const isDependent = Boolean(
26762
- triggerResourceScope &&
26763
- resourceDependencies.get(triggerResourceScope)?.has(resourceScope),
26836
+ const isDependent = isWriteHeardBy(
26837
+ resourceScope,
26838
+ triggeringAction,
26839
+ triggerResourceScope,
26764
26840
  );
26765
26841
  if (!isSameResource && !isDependent) {
26766
26842
  continue;
@@ -26825,6 +26901,35 @@ const shouldRerunAfter = (rerunConfig, verb) => {
26825
26901
  }
26826
26902
  return false;
26827
26903
  };
26904
+ // A `dependencies` entry: a resource (every write counts), or
26905
+ // `{ resource, on }` naming the write callbacks that do. A name that cannot
26906
+ // fire throws here: a dependency that never fires leaves a read on its old
26907
+ // value with nothing to say so.
26908
+ const resolveDependency = (dependency, dependentScope) => {
26909
+ if (!Object.hasOwn(dependency, "resource")) {
26910
+ return [dependency, WRITE_CALLBACK_NAMES];
26911
+ }
26912
+ const { resource: dependencyScope, on } = dependency;
26913
+ const where = `resource(${dependentScope.name}) dependency on ${dependencyScope.name}`;
26914
+ if (!Array.isArray(on) || on.length === 0) {
26915
+ throw new Error(
26916
+ `${where}: "on" lists the writes that count, e.g. on: ["POST", "DELETE"] — list the resource itself for every write`,
26917
+ );
26918
+ }
26919
+ for (const writeName of on) {
26920
+ if (!WRITE_CALLBACK_NAMES.includes(writeName)) {
26921
+ throw new Error(
26922
+ `${where}: "${writeName}" is not a write, "on" takes ${WRITE_CALLBACK_NAMES.join(", ")}`,
26923
+ );
26924
+ }
26925
+ if (!dependencyScope[writeName]) {
26926
+ throw new Error(
26927
+ `${where}: ${dependencyScope.name} has no ${writeName}, so it would never rerun ${dependentScope.name}`,
26928
+ );
26929
+ }
26930
+ }
26931
+ return [dependencyScope, on];
26932
+ };
26828
26933
  const isParamSubset = (parentParams, childParams) => {
26829
26934
  if (!parentParams || !childParams) {
26830
26935
  return false;
@@ -27573,6 +27678,10 @@ const debug$2 = (...args) => {
27573
27678
  * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, GET_RANGE, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
27574
27679
  * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"`
27575
27680
  * @param {string[]} [restCallbacks.uniqueKeys] - alternate keys the store can find an item by (e.g. `"username"`)
27681
+ * @param {Array<Object | { resource: Object, on: string[] }>} [restCallbacks.dependencies] -
27682
+ * resources whose writes rerun this one's reads: one listed bare counts every
27683
+ * write, `{ resource, on: ["PATCH_MANY", "DELETE"] }` only the callbacks it
27684
+ * names (`PATCH` is not `PATCH_MANY`)
27576
27685
  * @param {{ signal: import("@preact/signals").Signal, when?: () => boolean }} [restCallbacks.persist] -
27577
27686
  * mirrors the rows `GET` lands with into `signal` — a `stateSignal` with
27578
27687
  * `persists: true` and `type: "object"` keeps them across a reload, whose
@@ -29663,6 +29772,7 @@ const useAsyncData = (promiseOrAction, {
29663
29772
  loading = "delegate",
29664
29773
  error = "delegate",
29665
29774
  run,
29775
+ prerun,
29666
29776
  onLoad
29667
29777
  } = {}) => {
29668
29778
  // The request this component owns, as a function: one action per instance,
@@ -29688,6 +29798,7 @@ const useAsyncData = (promiseOrAction, {
29688
29798
  loadingEffect: loading,
29689
29799
  errorEffect: error,
29690
29800
  run,
29801
+ prerun,
29691
29802
  onLoad
29692
29803
  });
29693
29804
  }
@@ -29719,8 +29830,14 @@ const useActionAsyncData = (action, {
29719
29830
  loadingEffect,
29720
29831
  errorEffect,
29721
29832
  run,
29833
+ prerun,
29722
29834
  onLoad
29723
29835
  }) => {
29836
+ if (prerun) {
29837
+ // A head start draws none of what it starts, so it has no wait to
29838
+ // delegate.
29839
+ loadingEffect = "use";
29840
+ }
29724
29841
  const loadingRef = useContext(LoadingContext);
29725
29842
  // The boundary is where a suspension lands, so only the delegating path needs
29726
29843
  // one: `loading: true` draws the wait itself and never suspends.
@@ -29736,13 +29853,18 @@ const useActionAsyncData = (action, {
29736
29853
  // no-op — which is what makes starting it while rendering sound. From there
29737
29854
  // the wait is the ordinary one: suspended into <Loading>, or drawn by the
29738
29855
  // component under `loading: true`, exactly as for data someone else ran.
29739
- if (run && action.runningStateSignal.peek() === IDLE && action.paramsSignal.peek() !== undefined) {
29856
+ if (run && action.paramsSignal.peek() !== undefined && runIsOwed(action)) {
29740
29857
  // Nothing waits on this run: the failure is held by the action, and this
29741
29858
  // hook is what reads it back.
29742
29859
  runUnwatched(() => action.run({
29743
29860
  reason: "useAsyncData({ run: true })"
29744
29861
  }));
29745
29862
  }
29863
+ if (prerun && action.runningStateSignal.peek() === IDLE && action.paramsSignal.peek() !== undefined) {
29864
+ prerunUnwatched(() => action.prerun({
29865
+ reason: "useAsyncData({ prerun: true })"
29866
+ }));
29867
+ }
29746
29868
 
29747
29869
  // Use peek() instead of .value to avoid subscribing this component to the signal.
29748
29870
  // Reading .value would make Preact re-render the component reactively when the state
@@ -29851,6 +29973,11 @@ const useActionAsyncData = (action, {
29851
29973
  return [action.dataSignal.peek(), false, undefined];
29852
29974
  }
29853
29975
  if (runningState === FAILED) {
29976
+ if (prerun) {
29977
+ // Neither shown nor reported from a head start: the failure is the
29978
+ // owner's to ask again (see runIsOwed).
29979
+ return [action.dataSignal.peek(), false, undefined];
29980
+ }
29854
29981
  if (dismissedActionWeakSet.has(action)) {
29855
29982
  // Dismissing removes the error and nothing else: what is handed back is
29856
29983
  // the action's state without it — the data it holds, and no run in
@@ -29956,6 +30083,21 @@ const useActionAsyncData = (action, {
29956
30083
  throw pendingPromise;
29957
30084
  };
29958
30085
 
30086
+ // What `run: true` starts from: an action nobody ran, or one a prerun failed.
30087
+ // Any other failure stays — a render retrying it would loop while the network
30088
+ // is down — but nothing on screen asked for a prerun, so its failure is asked
30089
+ // again, once: the mark is taken here, and the run started fails as its own.
30090
+ const runIsOwed = action => {
30091
+ const runningState = action.runningStateSignal.peek();
30092
+ if (runningState === IDLE) {
30093
+ return true;
30094
+ }
30095
+ if (runningState === FAILED) {
30096
+ return takePrerunFailure(action.errorSignal.peek());
30097
+ }
30098
+ return false;
30099
+ };
30100
+
29959
30101
  // What a screen does with the data once, when it becomes known (see onLoad in
29960
30102
  // the JSDoc above). Kept apart because the two questions it answers are not the
29961
30103
  // ones the hook around it answers: WHEN — a layout effect, so a form taking its
@@ -39591,15 +39733,7 @@ const prefetchAsIf = (action, readParams, values) => {
39591
39733
  // the arrival focuses the link, or is the one that asked), and a reset
39592
39734
  // landing after that would pull the answer from under the page reading it.
39593
39735
  const prerunAhead = (instance) => {
39594
- let result;
39595
- try {
39596
- result = instance.prerun({ reason: "intent" });
39597
- } catch {
39598
- return;
39599
- }
39600
- if (result && typeof result.catch === "function") {
39601
- result.catch(() => {});
39602
- }
39736
+ prerunUnwatched(() => instance.prerun({ reason: "intent" }));
39603
39737
  };
39604
39738
 
39605
39739
  // I delibrately prefer the term "any" and avoid "some" so dev are not tempted to think
@@ -41687,7 +41821,7 @@ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
41687
41821
  * The target is found the way --navi-open finds it, but silently: nothing
41688
41822
  * runs here, and a source with no target says so at the release.
41689
41823
  */
41690
- const announceOpeningPress = (source, command) => {
41824
+ const announceOpeningPress = (source, command, pointerdownEvent) => {
41691
41825
  if (command !== "--navi-open" && command !== "--navi-toggle") {
41692
41826
  return;
41693
41827
  }
@@ -41698,7 +41832,7 @@ const announceOpeningPress = (source, command) => {
41698
41832
  if (!target) {
41699
41833
  return;
41700
41834
  }
41701
- dispatchCustomEvent(target, "navi_open_press");
41835
+ dispatchCustomEvent(target, "navi_open_press", { event: pointerdownEvent });
41702
41836
  };
41703
41837
  // "--navi-close:all" closes every expandable above the source, nearest first —
41704
41838
  // a link leaving from a badge shown over a sheet leaves both. A surface that
@@ -41971,11 +42105,18 @@ registerNaviCommand("--navi-unselect", (source, event) => {
41971
42105
  * where a delay is felt hardest — while the ~100-300ms between hovering a
41972
42106
  * trigger and pressing it are free. The warming render starts at the frame
41973
42107
  * after the one that follows the intent: nothing here needs the content in the
41974
- * DOM before the click, only before the open that follows it, and on a touch
41975
- * screen `pointerover` arrives together with the `pointerdown` — a build
41976
- * started there would hold back the frame that shows the press. Two animation
41977
- * frames, never a timeout: browsers hold timers back while a finger is down,
41978
- * and a timeout started by the press fires at its release, on top of the click.
42108
+ * DOM before the click, only before the open that follows it, and a build
42109
+ * started at once would hold back the frame that shows the hover or the focus.
42110
+ *
42111
+ * Under a finger, `pointerover` is not an intent yet: it arrives with the
42112
+ * `pointerdown` of every touch, the ones starting a scroll included, and at
42113
+ * reading speed the browser decides a touch is a scroll about 35ms in — a
42114
+ * build started then holds that scroll start back. So a touch warms once it has been held
42115
+ * TOUCH_WARM_HOLD_MS without the browser taking it (`pointercancel`) or the
42116
+ * finger lifting; a quicker tap builds at the open, like a click with no hover
42117
+ * before it. Counted in animation frames, never a timeout: browsers hold timers
42118
+ * back while a finger is down, and a timeout started by the press fires at its
42119
+ * release, on top of the click.
41979
42120
  *
41980
42121
  * The anchor only says where the popup is placed, so intent inside it is read
41981
42122
  * off the control it is aimed at, the nearest one around the target. A control
@@ -41999,8 +42140,11 @@ registerNaviCommand("--navi-unselect", (source, event) => {
41999
42140
  * anchor: it says where the popup is placed, not what opens it — a card
42000
42141
  * opened by a hold is pressed all day by taps meant for what it holds.
42001
42142
  *
42002
- * The press builds the content, and the press ending without an open throws
42003
- * it away — its click reaching the document with the popup still closed, no
42143
+ * The press builds the content when it starts, a touch once it has been held
42144
+ * as above: the announcement carries the pointerdown that made it. A press
42145
+ * released before its build leaves the building to the open its click asks
42146
+ * for. The press ending without an open throws the content away — its click
42147
+ * reaching the document with the popup still closed, no
42004
42148
  * click coming after the release, the browser taking the gesture
42005
42149
  * (`pointercancel`), or the next press starting, before any of its own
42006
42150
  * handlers run: one press at a time holds a content. A keyboard opening builds
@@ -42044,6 +42188,12 @@ const cancelIdle = (id) =>
42044
42188
  ? cancelIdleCallback(id)
42045
42189
  : clearTimeout(id);
42046
42190
 
42191
+ // How long a finger rests on an anchor before it warms it: past the ~35ms at
42192
+ // which the browser takes a touch moving at reading speed for a scroll, short
42193
+ // of the ~100ms a tap is held, so a deliberate tap is warmed before its
42194
+ // release.
42195
+ const TOUCH_WARM_HOLD_MS = 80;
42196
+
42047
42197
  const usePopupContentMount = (
42048
42198
  openController,
42049
42199
  ref,
@@ -42134,14 +42284,35 @@ const usePopupContentMount = (
42134
42284
  return undefined;
42135
42285
  }
42136
42286
  let cancelWarm = null;
42287
+ let warmIsTouchHold = false;
42137
42288
  const warm = () => {
42138
42289
  if (cancelWarm) {
42139
42290
  return;
42140
42291
  }
42292
+ warmIsTouchHold = false;
42141
42293
  cancelWarm = requestFrameAfterNext(() => {
42142
42294
  setContentMounted(true);
42143
42295
  });
42144
42296
  };
42297
+ const warmAfterTouchHold = () => {
42298
+ if (cancelWarm) {
42299
+ return;
42300
+ }
42301
+ warmIsTouchHold = true;
42302
+ cancelWarm = requestFrameAfterDuration(TOUCH_WARM_HOLD_MS, () => {
42303
+ setContentMounted(true);
42304
+ });
42305
+ };
42306
+ // A touch leaves the anchor when the finger lifts or the browser takes the
42307
+ // touch: either way, before the hold said anything.
42308
+ const onPointerLeave = () => {
42309
+ if (!warmIsTouchHold) {
42310
+ return;
42311
+ }
42312
+ cancelWarm();
42313
+ cancelWarm = null;
42314
+ warmIsTouchHold = false;
42315
+ };
42145
42316
  // The anchor itself when it is a control (a Popover anchored on the button
42146
42317
  // opening it), or the one the popup is written in (a picker's trigger).
42147
42318
  const isOwnControl = (control) =>
@@ -42161,6 +42332,10 @@ const usePopupContentMount = (
42161
42332
  return;
42162
42333
  }
42163
42334
  }
42335
+ if (pointeroverEvent.pointerType === "touch") {
42336
+ warmAfterTouchHold();
42337
+ return;
42338
+ }
42164
42339
  warm();
42165
42340
  };
42166
42341
  const onFocusIn = (focusinEvent) => {
@@ -42171,10 +42346,12 @@ const usePopupContentMount = (
42171
42346
  warm();
42172
42347
  };
42173
42348
  anchorElement.addEventListener("pointerover", onPointerOver);
42349
+ anchorElement.addEventListener("pointerleave", onPointerLeave);
42174
42350
  anchorElement.addEventListener("focusin", onFocusIn);
42175
42351
  return () => {
42176
42352
  cancelWarm?.();
42177
42353
  anchorElement.removeEventListener("pointerover", onPointerOver);
42354
+ anchorElement.removeEventListener("pointerleave", onPointerLeave);
42178
42355
  anchorElement.removeEventListener("focusin", onFocusIn);
42179
42356
  };
42180
42357
  }, [contentMounted, anchor, mount]);
@@ -42184,12 +42361,14 @@ const usePopupContentMount = (
42184
42361
  const stopPressWarmRef = useRef(null);
42185
42362
  openController.onOpeningPress =
42186
42363
  mount === "while-opened"
42187
- ? () => {
42364
+ ? (openPressEvent) => {
42188
42365
  if (contentMountedRef.current || openController.onOpen) {
42189
42366
  return;
42190
42367
  }
42191
- const cancelBuild = requestFrameAfterNext(() => {
42192
- setContentMounted(true);
42368
+ const cancelBuild = requestBuildOnPress(openPressEvent, {
42369
+ onBuild: () => {
42370
+ setContentMounted(true);
42371
+ },
42193
42372
  });
42194
42373
  const stopWatching = watchPressEnd(() => {
42195
42374
  stopPressWarmRef.current = null;
@@ -42251,6 +42430,64 @@ const requestFrameAfterNext = (callback) => {
42251
42430
  };
42252
42431
  };
42253
42432
 
42433
+ // What an opening press builds, it builds at the frame after the next one, so
42434
+ // the pressed trigger is painted first — under a finger, once the touch has
42435
+ // been held TOUCH_WARM_HOLD_MS (see the top comment). Released or taken by the
42436
+ // browser before then, the press builds nothing: the open its click asks for
42437
+ // builds, when one comes. Called from the press's own pointerdown, through its
42438
+ // navi_open_press: the capture listeners below only hear what follows.
42439
+ const requestBuildOnPress = (openPressEvent, { onBuild, onGiveUp }) => {
42440
+ const pointerdownEvent = openPressEvent.detail.event;
42441
+ const cancelFrame =
42442
+ pointerdownEvent.pointerType === "touch"
42443
+ ? requestFrameAfterDuration(TOUCH_WARM_HOLD_MS, () => {
42444
+ stopListening();
42445
+ onBuild();
42446
+ })
42447
+ : requestFrameAfterNext(() => {
42448
+ stopListening();
42449
+ onBuild();
42450
+ });
42451
+ const onPressEnd = (pointerEvent) => {
42452
+ if (pointerEvent.pointerId !== pointerdownEvent.pointerId) {
42453
+ return;
42454
+ }
42455
+ cancel();
42456
+ onGiveUp?.();
42457
+ };
42458
+ const stopListening = () => {
42459
+ document.removeEventListener("pointerup", onPressEnd, { capture: true });
42460
+ document.removeEventListener("pointercancel", onPressEnd, {
42461
+ capture: true,
42462
+ });
42463
+ };
42464
+ const cancel = () => {
42465
+ cancelFrame();
42466
+ stopListening();
42467
+ };
42468
+ document.addEventListener("pointerup", onPressEnd, { capture: true });
42469
+ document.addEventListener("pointercancel", onPressEnd, { capture: true });
42470
+ return cancel;
42471
+ };
42472
+
42473
+ // The first frame at least `ms` after the call, read off the frames' own
42474
+ // timestamps: a 120Hz screen waits as long as a 60Hz one.
42475
+ const requestFrameAfterDuration = (ms, callback) => {
42476
+ const start = performance.now();
42477
+ let frame;
42478
+ const onFrame = (timestamp) => {
42479
+ if (timestamp - start >= ms) {
42480
+ callback();
42481
+ return;
42482
+ }
42483
+ frame = requestAnimationFrame(onFrame);
42484
+ };
42485
+ frame = requestAnimationFrame(onFrame);
42486
+ return () => {
42487
+ cancelAnimationFrame(frame);
42488
+ };
42489
+ };
42490
+
42254
42491
  // How long the click of a released press may take to arrive. Nothing tells a
42255
42492
  // page that a release brings no click — a press that became a drag has its
42256
42493
  // click swallowed (suppressClickAfterGesture in @jsenv/dom) — so past this the
@@ -51435,7 +51672,7 @@ const usePreloadOpeningOnPress = (ref, command, prefetch = true) => {
51435
51672
  if (pointerdownEvent.button !== 0) {
51436
51673
  return;
51437
51674
  }
51438
- announceOpeningPress(element, command);
51675
+ announceOpeningPress(element, command, pointerdownEvent);
51439
51676
  };
51440
51677
  element.addEventListener("pointerdown", onPointerDown);
51441
51678
  return () => {
@@ -55624,7 +55861,9 @@ const Expandable = props => {
55624
55861
  // its own (see announceOpeningPress in commands.js) — unless aimed at a
55625
55862
  // control inside the UI part, whose click does not toggle (see onUIClick).
55626
55863
  if (pointerdownEvent.button === 0 && !openController.opened && !isAimedAtControlInsideUI(pointerdownEvent.target)) {
55627
- dispatchCustomEvent(rootRef.current, "navi_open_press");
55864
+ dispatchCustomEvent(rootRef.current, "navi_open_press", {
55865
+ event: pointerdownEvent
55866
+ });
55628
55867
  }
55629
55868
  };
55630
55869
 
@@ -56045,7 +56284,7 @@ const Expandable = props => {
56045
56284
  ,
56046
56285
  onnavi_open_press: e => {
56047
56286
  rest.onnavi_open_press?.(e);
56048
- openController.onOpeningPress?.();
56287
+ openController.onOpeningPress?.(e);
56049
56288
  },
56050
56289
  onnavi_request_close: e => {
56051
56290
  rest.onnavi_request_close?.(e);
@@ -56125,8 +56364,8 @@ const ExpandableUI = ({
56125
56364
  onnavi_request_open: e => {
56126
56365
  openController.open(e);
56127
56366
  },
56128
- onnavi_open_press: () => {
56129
- openController.onOpeningPress?.();
56367
+ onnavi_open_press: e => {
56368
+ openController.onOpeningPress?.(e);
56130
56369
  },
56131
56370
  onnavi_request_close: e => {
56132
56371
  openController.requestClose(e, {
@@ -68273,10 +68512,10 @@ const DialogStandIn = props => {
68273
68512
  }
68274
68513
  // eslint-disable-next-line react/no-unknown-property
68275
68514
  ,
68276
- onnavi_open_press: () => {
68515
+ onnavi_open_press: e => {
68277
68516
  // What the opening will read is asked for at once, as the dialog
68278
- // proper does (see open_controller.js); the dialog itself two frames
68279
- // later, so the pressed trigger is painted first — the click that
68517
+ // proper does (see open_controller.js); the dialog itself once the
68518
+ // press is taken for one (see requestBuildOnPress) — the click that
68280
68519
  // follows opens it built, or builds it itself when it comes sooner.
68281
68520
  if (signal) {
68282
68521
  preloadState(signal, readOpenValue(value));
@@ -68284,9 +68523,14 @@ const DialogStandIn = props => {
68284
68523
  if (warmRef.current) {
68285
68524
  return;
68286
68525
  }
68287
- warmRef.current = requestFrameAfterNext(() => {
68288
- warmRef.current = null;
68289
- build("press");
68526
+ warmRef.current = requestBuildOnPress(e, {
68527
+ onBuild: () => {
68528
+ warmRef.current = null;
68529
+ build("press");
68530
+ },
68531
+ onGiveUp: () => {
68532
+ warmRef.current = null;
68533
+ }
68290
68534
  });
68291
68535
  }
68292
68536
  });
@@ -68367,8 +68611,8 @@ const UncontrolledDialog = props => {
68367
68611
  // The press announcing that request (see announceOpeningPress in
68368
68612
  // commands.js), heard where the request is.
68369
68613
  ,
68370
- onnavi_open_press: () => {
68371
- openController.onOpeningPress?.();
68614
+ onnavi_open_press: e => {
68615
+ openController.onOpeningPress?.(e);
68372
68616
  },
68373
68617
  onnavi_request_close: e => {
68374
68618
  const closing = openController.requestClose(e, {
@@ -70255,8 +70499,8 @@ const UncontrolledPopover = props => {
70255
70499
  // The press announcing that request (see announceOpeningPress in
70256
70500
  // commands.js), heard where the request is.
70257
70501
  ,
70258
- onnavi_open_press: () => {
70259
- openController.onOpeningPress?.();
70502
+ onnavi_open_press: e => {
70503
+ openController.onOpeningPress?.(e);
70260
70504
  },
70261
70505
  onnavi_request_close: e => {
70262
70506
  const closing = openController.requestClose(e, {
@@ -71668,15 +71912,15 @@ const css$B = /* css */`.navi_picker_callout_dock > [data-picker-content] {
71668
71912
  display: flex;
71669
71913
  }
71670
71914
 
71915
+ &:has( > .navi_list_container:not([data-scroller], [data-overflow-visible])) {
71916
+ overflow: visible;
71917
+ }
71918
+
71671
71919
  & .navi_list_container {
71672
71920
  --list-border-radius: max(0px,
71673
71921
  var(--popover-border-radius) - var(--popover-border-width));
71674
71922
  overscroll-behavior: none;
71675
71923
  width: 100%;
71676
-
71677
- &:not([data-overflow-visible]) {
71678
- overflow: auto;
71679
- }
71680
71924
  }
71681
71925
  }
71682
71926
 
@@ -71695,15 +71939,15 @@ const css$B = /* css */`.navi_picker_callout_dock > [data-picker-content] {
71695
71939
  flex-direction: column;
71696
71940
  }
71697
71941
 
71942
+ &:has( > .navi_list_container:not([data-scroller], [data-overflow-visible])) {
71943
+ overflow: visible;
71944
+ }
71945
+
71698
71946
  & .navi_list_container {
71699
71947
  --list-border-radius: max(0px,
71700
71948
  var(--dialog-border-radius) - var(--dialog-border-width));
71701
71949
  overscroll-behavior: none;
71702
71950
  width: 100%;
71703
-
71704
- &:not([data-overflow-visible]) {
71705
- overflow: auto;
71706
- }
71707
71951
  }
71708
71952
  }
71709
71953
  `;
@@ -72162,8 +72406,8 @@ const PickerCustom = props => {
72162
72406
  },
72163
72407
  // And the press announcing that request (see announceOpeningPress in
72164
72408
  // commands.js), for the popup's content to be built on it.
72165
- "onnavi_open_press": () => {
72166
- dispatchCustomEvent(popupRef.current, "navi_open_press");
72409
+ "onnavi_open_press": e => {
72410
+ dispatchCustomEvent(popupRef.current, "navi_open_press", e.detail);
72167
72411
  },
72168
72412
  "onnavi_request_close": e => {
72169
72413
  const closing = dispatchCustomEvent(popupRef.current, "navi_request_close", e.detail);
@@ -72226,8 +72470,8 @@ const PickerCustom = props => {
72226
72470
  // The press announcing that request (see announceOpeningPress in
72227
72471
  // commands.js), heard where the request is — here too for the same
72228
72472
  // reason. Nothing to gate: the announcer asked before saying it.
72229
- onnavi_open_press: () => {
72230
- openController.onOpeningPress?.();
72473
+ onnavi_open_press: e => {
72474
+ openController.onOpeningPress?.(e);
72231
72475
  },
72232
72476
  onnavi_request_close: e => {
72233
72477
  requestInteraction({
@@ -72380,7 +72624,9 @@ const PickerCustom = props => {
72380
72624
  })) {
72381
72625
  return;
72382
72626
  }
72383
- dispatchCustomEvent(pickerEl, "navi_open_press");
72627
+ dispatchCustomEvent(pickerEl, "navi_open_press", {
72628
+ event: pointerdownEvent
72629
+ });
72384
72630
  };
72385
72631
  }
72386
72632
  let interactions = props.interactions;
@@ -75206,15 +75452,13 @@ const css$x = /* css */`@property --list-group-label-height {
75206
75452
  flex-direction: column;
75207
75453
  transition: opacity .2s;
75208
75454
  display: flex;
75209
- overflow: hidden;
75210
75455
 
75211
- &[data-borderless] {
75212
- --x-list-border-radius: 0px;
75213
- overflow: visible;
75456
+ &:not([data-borderless], [data-overflow-visible], [data-scroller]) {
75457
+ min-height: 0;
75214
75458
  }
75215
75459
 
75216
- &[data-overflow-visible="both"] {
75217
- overflow: visible;
75460
+ &[data-borderless] {
75461
+ --x-list-border-radius: 0px;
75218
75462
  }
75219
75463
 
75220
75464
  &[data-overflow-visible="x"] {