@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.
@@ -1895,6 +1895,27 @@ const markErrorAsTakenByRender = (error) => {
1895
1895
  }
1896
1896
  };
1897
1897
 
1898
+ /**
1899
+ * A prerun's failure is owed to no screen: nothing on screen asked for that
1900
+ * run, so the component that owns the read asks again rather than show a
1901
+ * failure from before it existed (see `run` in use_async_data.jsx). Taken
1902
+ * once, so the run asking again fails as its own even when it fails with the
1903
+ * same error object. Held by the error, it lasts as long as the failure stays
1904
+ * on the action: a later run replaces it, whether or not an owner ever came.
1905
+ */
1906
+ const prerunFailureSet = new WeakSet();
1907
+ const markErrorAsPrerunFailure = (error) => {
1908
+ if (error && typeof error === "object") {
1909
+ prerunFailureSet.add(error);
1910
+ }
1911
+ };
1912
+ const takePrerunFailure = (error) => {
1913
+ if (!error || typeof error !== "object") {
1914
+ return false;
1915
+ }
1916
+ return prerunFailureSet.delete(error);
1917
+ };
1918
+
1898
1919
  /**
1899
1920
  * When the answer "nobody took it" is final.
1900
1921
  *
@@ -4194,6 +4215,9 @@ const createAction = (callback, rootOptions = {}) => {
4194
4215
  return error;
4195
4216
  }
4196
4217
  error.action = action;
4218
+ if (isPrerun) {
4219
+ markErrorAsPrerunFailure(error);
4220
+ }
4197
4221
  batch(() => {
4198
4222
  errorSignal.value = error;
4199
4223
  runningStateSignal.value = FAILED;
@@ -4767,6 +4791,25 @@ const runUnwatched = (startRun) => {
4767
4791
  return result;
4768
4792
  };
4769
4793
 
4794
+ /**
4795
+ * Starts a prerun navi has nobody to await. Its failure is not reported:
4796
+ * nothing on screen asked for it. It stays on the action, marked as a
4797
+ * prerun's (see markErrorAsPrerunFailure), where whatever owns the read asks
4798
+ * again.
4799
+ */
4800
+ const prerunUnwatched = (startPrerun) => {
4801
+ let result;
4802
+ try {
4803
+ result = startPrerun();
4804
+ } catch {
4805
+ return undefined;
4806
+ }
4807
+ if (result && typeof result.catch === "function") {
4808
+ result.catch(() => {});
4809
+ }
4810
+ return result;
4811
+ };
4812
+
4770
4813
  /**
4771
4814
  * Reactively runs an action whenever the params derived from signals change.
4772
4815
  *
@@ -10882,27 +10925,29 @@ const updateDocumentState = (value) => {
10882
10925
  * `state`:
10883
10926
  * - `undefined` — a neutral navigation: a link click, a `replaceUrl` writing a
10884
10927
  * search param. A **replace** stays on the entry the document is already on,
10885
- * so what that entry holds stays with it. A **push** opens a NEW entry, and
10886
- * what was written for the one being left — an open dialog, an expanded
10887
- * picker (see `useNavState`) — describes that entry alone: carried forward it
10888
- * would reopen on the next screen, and on the one after that, until something
10889
- * mounting the same id opens out of nowhere.
10928
+ * so what that entry holds stays with it. A **push** opens a NEW entry. One
10929
+ * arriving at another page (`isArrival`, see scroll_restoration.js) starts
10930
+ * empty: what was written for the entry being left — an open dialog, an
10931
+ * expanded picker (see `useNavState`) — describes that entry alone, and
10932
+ * carried forward it would reopen on the next screen, and on the one after
10933
+ * that, until something mounting the same id opens out of nowhere. A push
10934
+ * that keeps the page (a layer bound to a search param, opened over it)
10935
+ * carries the state: whatever holds it is still mounted under the new
10936
+ * address, and an empty entry would close it there — an expanded row
10937
+ * collapsing under the layer, expanding again when the back takes it away.
10890
10938
  * - `null` — an explicit reset.
10891
10939
  * - an object — built by the caller (`enter()`/`leave()` copy the current state
10892
10940
  * themselves), taken as given.
10893
10941
  *
10894
- * `sharedState` always wins: it describes the document, not the entry.
10895
- *
10896
- * The push/replace split is the Navigation API's own rule for a `navigate()`
10897
- * carrying no state — which is why via_navigation.js gets it from the browser
10898
- * and via_history.js has to spell it out to say the same thing.
10942
+ * `currentState` is what the entry being left holds. `sharedState` always
10943
+ * wins: it describes the document, not the entry.
10899
10944
  */
10900
10945
  const resolveEffectiveDocumentState = (
10901
10946
  state,
10902
- { navigationType, currentState, sharedState },
10947
+ { navigationType, isArrival, currentState, sharedState },
10903
10948
  ) => {
10904
10949
  if (state === undefined) {
10905
- if (navigationType === "push") {
10950
+ if (navigationType === "push" && isArrival) {
10906
10951
  return sharedState;
10907
10952
  }
10908
10953
  return { ...(currentState || {}), ...sharedState };
@@ -11446,6 +11491,8 @@ const startAtTop = (url, { from } = {}) => {
11446
11491
  positionByUrl.set(href, { x: 0, y: 0 });
11447
11492
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
11448
11493
  };
11494
+ // Also what decides whether a push carries the state of the page it keeps
11495
+ // (see resolveEffectiveDocumentState).
11449
11496
  const isArrival = (url, { from }) => {
11450
11497
  if (
11451
11498
  from !== undefined &&
@@ -11881,7 +11928,8 @@ const setupBrowserIntegrationViaHistory = ({
11881
11928
 
11882
11929
  const applyRoutingTask = (url, options) => {
11883
11930
  // Read before the history is written: the url the reader is being taken
11884
- // away from, which is what decides where a push lands (see startAtTop).
11931
+ // away from, which is what decides where a push lands (see startAtTop) and
11932
+ // whether it carries the state (see resolveEffectiveDocumentState).
11885
11933
  const urlLeft = readAddress();
11886
11934
  const {
11887
11935
  reason,
@@ -11901,6 +11949,7 @@ const setupBrowserIntegrationViaHistory = ({
11901
11949
  markUrlAsVisited(url);
11902
11950
  const effectiveState = resolveEffectiveDocumentState(state, {
11903
11951
  navigationType,
11952
+ isArrival: isArrival(url, { from: urlLeft }),
11904
11953
  currentState: getDocumentState(),
11905
11954
  sharedState: {
11906
11955
  jsenv_visited_urls: Array.from(visitedUrlSet),
@@ -25567,10 +25616,24 @@ const resolveRerunOn = (rerunOn, inheritedRerunOn = defaultRerunOn) => {
25567
25616
  };
25568
25617
  };
25569
25618
 
25619
+ // What a `dependencies` entry names are callbacks, not verbs (`rerunOn` names
25620
+ // verbs, where `PATCH` covers `PATCH_MANY`): one callback of a resource can be
25621
+ // the write that creates (a move into a new item) while its sibling only edits.
25622
+ const WRITE_CALLBACK_NAMES = [
25623
+ "POST",
25624
+ "POST_MANY",
25625
+ "PUT",
25626
+ "PUT_MANY",
25627
+ "PATCH",
25628
+ "PATCH_MANY",
25629
+ "DELETE",
25630
+ "DELETE_MANY",
25631
+ ];
25632
+
25570
25633
  // This handles ALL resource lifecycle logic (rerun/reset) across all resources
25571
25634
  const createResourceLifecycleManager = () => {
25572
25635
  const registeredResources = new Map(); // Map<resourceInstance, lifecycleConfig>
25573
- const resourceDependencies = new Map(); // Map<resourceInstance, Set<dependentResources>> — user-configured
25636
+ const resourceDependencies = new Map(); // Map<resourceInstance, Map<dependentResource, Set<writeCallbackName>>> — user-configured
25574
25637
  const scopedManyParents = new Map(); // Map<childResource, Set<{resource, propertyName}>> — auto from scopedMany
25575
25638
 
25576
25639
  const registerResource = (resourceScope, config) => {
@@ -25589,16 +25652,38 @@ const createResourceLifecycleManager = () => {
25589
25652
  rangeReaderSet: new Set(),
25590
25653
  });
25591
25654
 
25592
- // Register dependencies
25593
- if (dependencies.length > 0) {
25594
- for (const dependency of dependencies) {
25595
- if (!resourceDependencies.has(dependency)) {
25596
- resourceDependencies.set(dependency, new Set());
25597
- }
25598
- resourceDependencies.get(dependency).add(resourceScope);
25655
+ for (const dependency of dependencies) {
25656
+ const [dependencyScope, writeNames] = resolveDependency(
25657
+ dependency,
25658
+ resourceScope,
25659
+ );
25660
+ let dependentMap = resourceDependencies.get(dependencyScope);
25661
+ if (!dependentMap) {
25662
+ dependentMap = new Map();
25663
+ resourceDependencies.set(dependencyScope, dependentMap);
25664
+ }
25665
+ let writeNameSet = dependentMap.get(resourceScope);
25666
+ if (!writeNameSet) {
25667
+ writeNameSet = new Set();
25668
+ dependentMap.set(resourceScope, writeNameSet);
25669
+ }
25670
+ for (const writeName of writeNames) {
25671
+ writeNameSet.add(writeName);
25599
25672
  }
25600
25673
  }
25601
25674
  };
25675
+ // Whether `resourceScope` listed the facade that wrote, with this write
25676
+ // among the ones that count.
25677
+ const isWriteHeardBy = (resourceScope, triggeringAction, triggerScope) => {
25678
+ const writeNameSet = resourceDependencies
25679
+ .get(triggerScope)
25680
+ ?.get(resourceScope);
25681
+ if (!writeNameSet) {
25682
+ return false;
25683
+ }
25684
+ const { verb, isMany } = triggeringAction.meta;
25685
+ return writeNameSet.has(isMany ? `${verb}_MANY` : verb);
25686
+ };
25602
25687
  // Only the reader the resource exposes is registered; the ones `bindParams`
25603
25688
  // makes share its signal (see resource_range_reader.js).
25604
25689
  const registerRangeReader = (resourceScope, rangeReader) => {
@@ -25639,17 +25724,18 @@ const createResourceLifecycleManager = () => {
25639
25724
  triggerVerb === "PATCH") &&
25640
25725
  config.uniqueKeys.length > 0;
25641
25726
 
25642
- const isKnownDependency =
25643
- triggerResourceScope !== null &&
25644
- triggerResourceScope !== undefined &&
25645
- resourceDependencies.get(triggerResourceScope)?.has(resourceScope);
25727
+ const isHeardDependency = isWriteHeardBy(
25728
+ resourceScope,
25729
+ triggeringAction,
25730
+ triggerResourceScope,
25731
+ );
25646
25732
 
25647
25733
  if (
25648
25734
  !shouldRerunGetMany &&
25649
25735
  !shouldRerunGet &&
25650
25736
  triggerVerb !== "DELETE" &&
25651
25737
  !hasUniqueKeyAutorerun &&
25652
- !isKnownDependency
25738
+ !isHeardDependency
25653
25739
  ) {
25654
25740
  continue;
25655
25741
  }
@@ -25770,21 +25856,10 @@ const createResourceLifecycleManager = () => {
25770
25856
  }
25771
25857
 
25772
25858
  // Cross-resource dependency effects: rerun dependent GET / GET_MANY
25773
- // Fires on any mutating verb — user-configured dependencies express
25774
- // "this resource depends on another resource's data", so any mutation
25775
- // (POST, PUT, PATCH, DELETE) on the dependency should trigger a rerun.
25859
+ // after a write the dependency counts — every write for a resource
25860
+ // listed bare, the callbacks named in `on` otherwise.
25776
25861
  {
25777
- if (
25778
- triggerResourceScope &&
25779
- resourceDependencies
25780
- .get(triggerResourceScope)
25781
- ?.has(resourceScope) &&
25782
- (triggerVerb === "POST" ||
25783
- triggerVerb === "PUT" ||
25784
- triggerVerb === "PATCH" ||
25785
- triggerVerb === "DELETE") &&
25786
- candidateVerb === "GET"
25787
- ) {
25862
+ if (isHeardDependency && candidateVerb === "GET") {
25788
25863
  actionsToRerun.add(actionCandidate);
25789
25864
  reasonSet.add("dependency autorerun");
25790
25865
  continue;
@@ -25861,9 +25936,10 @@ const createResourceLifecycleManager = () => {
25861
25936
  continue;
25862
25937
  }
25863
25938
  const isSameResource = resourceScope === triggerResourceScope;
25864
- const isDependent = Boolean(
25865
- triggerResourceScope &&
25866
- resourceDependencies.get(triggerResourceScope)?.has(resourceScope),
25939
+ const isDependent = isWriteHeardBy(
25940
+ resourceScope,
25941
+ triggeringAction,
25942
+ triggerResourceScope,
25867
25943
  );
25868
25944
  if (!isSameResource && !isDependent) {
25869
25945
  continue;
@@ -25928,6 +26004,35 @@ const shouldRerunAfter = (rerunConfig, verb) => {
25928
26004
  }
25929
26005
  return false;
25930
26006
  };
26007
+ // A `dependencies` entry: a resource (every write counts), or
26008
+ // `{ resource, on }` naming the write callbacks that do. A name that cannot
26009
+ // fire throws here: a dependency that never fires leaves a read on its old
26010
+ // value with nothing to say so.
26011
+ const resolveDependency = (dependency, dependentScope) => {
26012
+ if (!Object.hasOwn(dependency, "resource")) {
26013
+ return [dependency, WRITE_CALLBACK_NAMES];
26014
+ }
26015
+ const { resource: dependencyScope, on } = dependency;
26016
+ const where = `resource(${dependentScope.name}) dependency on ${dependencyScope.name}`;
26017
+ if (!Array.isArray(on) || on.length === 0) {
26018
+ throw new Error(
26019
+ `${where}: "on" lists the writes that count, e.g. on: ["POST", "DELETE"] — list the resource itself for every write`,
26020
+ );
26021
+ }
26022
+ for (const writeName of on) {
26023
+ if (!WRITE_CALLBACK_NAMES.includes(writeName)) {
26024
+ throw new Error(
26025
+ `${where}: "${writeName}" is not a write, "on" takes ${WRITE_CALLBACK_NAMES.join(", ")}`,
26026
+ );
26027
+ }
26028
+ if (!dependencyScope[writeName]) {
26029
+ throw new Error(
26030
+ `${where}: ${dependencyScope.name} has no ${writeName}, so it would never rerun ${dependentScope.name}`,
26031
+ );
26032
+ }
26033
+ }
26034
+ return [dependencyScope, on];
26035
+ };
25931
26036
  const isParamSubset = (parentParams, childParams) => {
25932
26037
  if (!parentParams || !childParams) {
25933
26038
  return false;
@@ -26676,6 +26781,10 @@ const debug$2 = (...args) => {
26676
26781
  * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, GET_RANGE, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
26677
26782
  * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"`
26678
26783
  * @param {string[]} [restCallbacks.uniqueKeys] - alternate keys the store can find an item by (e.g. `"username"`)
26784
+ * @param {Array<Object | { resource: Object, on: string[] }>} [restCallbacks.dependencies] -
26785
+ * resources whose writes rerun this one's reads: one listed bare counts every
26786
+ * write, `{ resource, on: ["PATCH_MANY", "DELETE"] }` only the callbacks it
26787
+ * names (`PATCH` is not `PATCH_MANY`)
26679
26788
  * @param {{ signal: import("@preact/signals").Signal, when?: () => boolean }} [restCallbacks.persist] -
26680
26789
  * mirrors the rows `GET` lands with into `signal` — a `stateSignal` with
26681
26790
  * `persists: true` and `type: "object"` keeps them across a reload, whose
@@ -28766,6 +28875,7 @@ const useAsyncData = (promiseOrAction, {
28766
28875
  loading = "delegate",
28767
28876
  error = "delegate",
28768
28877
  run,
28878
+ prerun,
28769
28879
  onLoad
28770
28880
  } = {}) => {
28771
28881
  // The request this component owns, as a function: one action per instance,
@@ -28791,6 +28901,7 @@ const useAsyncData = (promiseOrAction, {
28791
28901
  loadingEffect: loading,
28792
28902
  errorEffect: error,
28793
28903
  run,
28904
+ prerun,
28794
28905
  onLoad
28795
28906
  });
28796
28907
  }
@@ -28822,8 +28933,14 @@ const useActionAsyncData = (action, {
28822
28933
  loadingEffect,
28823
28934
  errorEffect,
28824
28935
  run,
28936
+ prerun,
28825
28937
  onLoad
28826
28938
  }) => {
28939
+ if (prerun) {
28940
+ // A head start draws none of what it starts, so it has no wait to
28941
+ // delegate.
28942
+ loadingEffect = "use";
28943
+ }
28827
28944
  const loadingRef = useContext(LoadingContext);
28828
28945
  // The boundary is where a suspension lands, so only the delegating path needs
28829
28946
  // one: `loading: true` draws the wait itself and never suspends.
@@ -28839,13 +28956,18 @@ const useActionAsyncData = (action, {
28839
28956
  // no-op — which is what makes starting it while rendering sound. From there
28840
28957
  // the wait is the ordinary one: suspended into <Loading>, or drawn by the
28841
28958
  // component under `loading: true`, exactly as for data someone else ran.
28842
- if (run && action.runningStateSignal.peek() === IDLE && action.paramsSignal.peek() !== undefined) {
28959
+ if (run && action.paramsSignal.peek() !== undefined && runIsOwed(action)) {
28843
28960
  // Nothing waits on this run: the failure is held by the action, and this
28844
28961
  // hook is what reads it back.
28845
28962
  runUnwatched(() => action.run({
28846
28963
  reason: "useAsyncData({ run: true })"
28847
28964
  }));
28848
28965
  }
28966
+ if (prerun && action.runningStateSignal.peek() === IDLE && action.paramsSignal.peek() !== undefined) {
28967
+ prerunUnwatched(() => action.prerun({
28968
+ reason: "useAsyncData({ prerun: true })"
28969
+ }));
28970
+ }
28849
28971
 
28850
28972
  // Use peek() instead of .value to avoid subscribing this component to the signal.
28851
28973
  // Reading .value would make Preact re-render the component reactively when the state
@@ -28954,6 +29076,11 @@ const useActionAsyncData = (action, {
28954
29076
  return [action.dataSignal.peek(), false, undefined];
28955
29077
  }
28956
29078
  if (runningState === FAILED) {
29079
+ if (prerun) {
29080
+ // Neither shown nor reported from a head start: the failure is the
29081
+ // owner's to ask again (see runIsOwed).
29082
+ return [action.dataSignal.peek(), false, undefined];
29083
+ }
28957
29084
  if (dismissedActionWeakSet.has(action)) {
28958
29085
  // Dismissing removes the error and nothing else: what is handed back is
28959
29086
  // the action's state without it — the data it holds, and no run in
@@ -29059,6 +29186,21 @@ const useActionAsyncData = (action, {
29059
29186
  throw pendingPromise;
29060
29187
  };
29061
29188
 
29189
+ // What `run: true` starts from: an action nobody ran, or one a prerun failed.
29190
+ // Any other failure stays — a render retrying it would loop while the network
29191
+ // is down — but nothing on screen asked for a prerun, so its failure is asked
29192
+ // again, once: the mark is taken here, and the run started fails as its own.
29193
+ const runIsOwed = action => {
29194
+ const runningState = action.runningStateSignal.peek();
29195
+ if (runningState === IDLE) {
29196
+ return true;
29197
+ }
29198
+ if (runningState === FAILED) {
29199
+ return takePrerunFailure(action.errorSignal.peek());
29200
+ }
29201
+ return false;
29202
+ };
29203
+
29062
29204
  // What a screen does with the data once, when it becomes known (see onLoad in
29063
29205
  // the JSDoc above). Kept apart because the two questions it answers are not the
29064
29206
  // ones the hook around it answers: WHEN — a layout effect, so a form taking its
@@ -38595,15 +38737,7 @@ const prefetchAsIf = (action, readParams, values) => {
38595
38737
  // the arrival focuses the link, or is the one that asked), and a reset
38596
38738
  // landing after that would pull the answer from under the page reading it.
38597
38739
  const prerunAhead = (instance) => {
38598
- let result;
38599
- try {
38600
- result = instance.prerun({ reason: "intent" });
38601
- } catch {
38602
- return;
38603
- }
38604
- if (result && typeof result.catch === "function") {
38605
- result.catch(() => {});
38606
- }
38740
+ prerunUnwatched(() => instance.prerun({ reason: "intent" }));
38607
38741
  };
38608
38742
 
38609
38743
  // I delibrately prefer the term "any" and avoid "some" so dev are not tempted to think
@@ -40640,7 +40774,7 @@ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
40640
40774
  * The target is found the way --navi-open finds it, but silently: nothing
40641
40775
  * runs here, and a source with no target says so at the release.
40642
40776
  */
40643
- const announceOpeningPress = (source, command) => {
40777
+ const announceOpeningPress = (source, command, pointerdownEvent) => {
40644
40778
  if (command !== "--navi-open" && command !== "--navi-toggle") {
40645
40779
  return;
40646
40780
  }
@@ -40651,7 +40785,7 @@ const announceOpeningPress = (source, command) => {
40651
40785
  if (!target) {
40652
40786
  return;
40653
40787
  }
40654
- dispatchCustomEvent(target, "navi_open_press");
40788
+ dispatchCustomEvent(target, "navi_open_press", { event: pointerdownEvent });
40655
40789
  };
40656
40790
  // "--navi-close:all" closes every expandable above the source, nearest first —
40657
40791
  // a link leaving from a badge shown over a sheet leaves both. A surface that
@@ -40924,11 +41058,18 @@ registerNaviCommand("--navi-unselect", (source, event) => {
40924
41058
  * where a delay is felt hardest — while the ~100-300ms between hovering a
40925
41059
  * trigger and pressing it are free. The warming render starts at the frame
40926
41060
  * after the one that follows the intent: nothing here needs the content in the
40927
- * DOM before the click, only before the open that follows it, and on a touch
40928
- * screen `pointerover` arrives together with the `pointerdown` — a build
40929
- * started there would hold back the frame that shows the press. Two animation
40930
- * frames, never a timeout: browsers hold timers back while a finger is down,
40931
- * and a timeout started by the press fires at its release, on top of the click.
41061
+ * DOM before the click, only before the open that follows it, and a build
41062
+ * started at once would hold back the frame that shows the hover or the focus.
41063
+ *
41064
+ * Under a finger, `pointerover` is not an intent yet: it arrives with the
41065
+ * `pointerdown` of every touch, the ones starting a scroll included, and at
41066
+ * reading speed the browser decides a touch is a scroll about 35ms in — a
41067
+ * build started then holds that scroll start back. So a touch warms once it has been held
41068
+ * TOUCH_WARM_HOLD_MS without the browser taking it (`pointercancel`) or the
41069
+ * finger lifting; a quicker tap builds at the open, like a click with no hover
41070
+ * before it. Counted in animation frames, never a timeout: browsers hold timers
41071
+ * back while a finger is down, and a timeout started by the press fires at its
41072
+ * release, on top of the click.
40932
41073
  *
40933
41074
  * The anchor only says where the popup is placed, so intent inside it is read
40934
41075
  * off the control it is aimed at, the nearest one around the target. A control
@@ -40952,8 +41093,11 @@ registerNaviCommand("--navi-unselect", (source, event) => {
40952
41093
  * anchor: it says where the popup is placed, not what opens it — a card
40953
41094
  * opened by a hold is pressed all day by taps meant for what it holds.
40954
41095
  *
40955
- * The press builds the content, and the press ending without an open throws
40956
- * it away — its click reaching the document with the popup still closed, no
41096
+ * The press builds the content when it starts, a touch once it has been held
41097
+ * as above: the announcement carries the pointerdown that made it. A press
41098
+ * released before its build leaves the building to the open its click asks
41099
+ * for. The press ending without an open throws the content away — its click
41100
+ * reaching the document with the popup still closed, no
40957
41101
  * click coming after the release, the browser taking the gesture
40958
41102
  * (`pointercancel`), or the next press starting, before any of its own
40959
41103
  * handlers run: one press at a time holds a content. A keyboard opening builds
@@ -40997,6 +41141,12 @@ const cancelIdle = (id) =>
40997
41141
  ? cancelIdleCallback(id)
40998
41142
  : clearTimeout(id);
40999
41143
 
41144
+ // How long a finger rests on an anchor before it warms it: past the ~35ms at
41145
+ // which the browser takes a touch moving at reading speed for a scroll, short
41146
+ // of the ~100ms a tap is held, so a deliberate tap is warmed before its
41147
+ // release.
41148
+ const TOUCH_WARM_HOLD_MS = 80;
41149
+
41000
41150
  const usePopupContentMount = (
41001
41151
  openController,
41002
41152
  ref,
@@ -41087,14 +41237,35 @@ const usePopupContentMount = (
41087
41237
  return undefined;
41088
41238
  }
41089
41239
  let cancelWarm = null;
41240
+ let warmIsTouchHold = false;
41090
41241
  const warm = () => {
41091
41242
  if (cancelWarm) {
41092
41243
  return;
41093
41244
  }
41245
+ warmIsTouchHold = false;
41094
41246
  cancelWarm = requestFrameAfterNext(() => {
41095
41247
  setContentMounted(true);
41096
41248
  });
41097
41249
  };
41250
+ const warmAfterTouchHold = () => {
41251
+ if (cancelWarm) {
41252
+ return;
41253
+ }
41254
+ warmIsTouchHold = true;
41255
+ cancelWarm = requestFrameAfterDuration(TOUCH_WARM_HOLD_MS, () => {
41256
+ setContentMounted(true);
41257
+ });
41258
+ };
41259
+ // A touch leaves the anchor when the finger lifts or the browser takes the
41260
+ // touch: either way, before the hold said anything.
41261
+ const onPointerLeave = () => {
41262
+ if (!warmIsTouchHold) {
41263
+ return;
41264
+ }
41265
+ cancelWarm();
41266
+ cancelWarm = null;
41267
+ warmIsTouchHold = false;
41268
+ };
41098
41269
  // The anchor itself when it is a control (a Popover anchored on the button
41099
41270
  // opening it), or the one the popup is written in (a picker's trigger).
41100
41271
  const isOwnControl = (control) =>
@@ -41114,6 +41285,10 @@ const usePopupContentMount = (
41114
41285
  return;
41115
41286
  }
41116
41287
  }
41288
+ if (pointeroverEvent.pointerType === "touch") {
41289
+ warmAfterTouchHold();
41290
+ return;
41291
+ }
41117
41292
  warm();
41118
41293
  };
41119
41294
  const onFocusIn = (focusinEvent) => {
@@ -41124,10 +41299,12 @@ const usePopupContentMount = (
41124
41299
  warm();
41125
41300
  };
41126
41301
  anchorElement.addEventListener("pointerover", onPointerOver);
41302
+ anchorElement.addEventListener("pointerleave", onPointerLeave);
41127
41303
  anchorElement.addEventListener("focusin", onFocusIn);
41128
41304
  return () => {
41129
41305
  cancelWarm?.();
41130
41306
  anchorElement.removeEventListener("pointerover", onPointerOver);
41307
+ anchorElement.removeEventListener("pointerleave", onPointerLeave);
41131
41308
  anchorElement.removeEventListener("focusin", onFocusIn);
41132
41309
  };
41133
41310
  }, [contentMounted, anchor, mount]);
@@ -41137,12 +41314,14 @@ const usePopupContentMount = (
41137
41314
  const stopPressWarmRef = useRef(null);
41138
41315
  openController.onOpeningPress =
41139
41316
  mount === "while-opened"
41140
- ? () => {
41317
+ ? (openPressEvent) => {
41141
41318
  if (contentMountedRef.current || openController.onOpen) {
41142
41319
  return;
41143
41320
  }
41144
- const cancelBuild = requestFrameAfterNext(() => {
41145
- setContentMounted(true);
41321
+ const cancelBuild = requestBuildOnPress(openPressEvent, {
41322
+ onBuild: () => {
41323
+ setContentMounted(true);
41324
+ },
41146
41325
  });
41147
41326
  const stopWatching = watchPressEnd(() => {
41148
41327
  stopPressWarmRef.current = null;
@@ -41204,6 +41383,64 @@ const requestFrameAfterNext = (callback) => {
41204
41383
  };
41205
41384
  };
41206
41385
 
41386
+ // What an opening press builds, it builds at the frame after the next one, so
41387
+ // the pressed trigger is painted first — under a finger, once the touch has
41388
+ // been held TOUCH_WARM_HOLD_MS (see the top comment). Released or taken by the
41389
+ // browser before then, the press builds nothing: the open its click asks for
41390
+ // builds, when one comes. Called from the press's own pointerdown, through its
41391
+ // navi_open_press: the capture listeners below only hear what follows.
41392
+ const requestBuildOnPress = (openPressEvent, { onBuild, onGiveUp }) => {
41393
+ const pointerdownEvent = openPressEvent.detail.event;
41394
+ const cancelFrame =
41395
+ pointerdownEvent.pointerType === "touch"
41396
+ ? requestFrameAfterDuration(TOUCH_WARM_HOLD_MS, () => {
41397
+ stopListening();
41398
+ onBuild();
41399
+ })
41400
+ : requestFrameAfterNext(() => {
41401
+ stopListening();
41402
+ onBuild();
41403
+ });
41404
+ const onPressEnd = (pointerEvent) => {
41405
+ if (pointerEvent.pointerId !== pointerdownEvent.pointerId) {
41406
+ return;
41407
+ }
41408
+ cancel();
41409
+ onGiveUp?.();
41410
+ };
41411
+ const stopListening = () => {
41412
+ document.removeEventListener("pointerup", onPressEnd, { capture: true });
41413
+ document.removeEventListener("pointercancel", onPressEnd, {
41414
+ capture: true,
41415
+ });
41416
+ };
41417
+ const cancel = () => {
41418
+ cancelFrame();
41419
+ stopListening();
41420
+ };
41421
+ document.addEventListener("pointerup", onPressEnd, { capture: true });
41422
+ document.addEventListener("pointercancel", onPressEnd, { capture: true });
41423
+ return cancel;
41424
+ };
41425
+
41426
+ // The first frame at least `ms` after the call, read off the frames' own
41427
+ // timestamps: a 120Hz screen waits as long as a 60Hz one.
41428
+ const requestFrameAfterDuration = (ms, callback) => {
41429
+ const start = performance.now();
41430
+ let frame;
41431
+ const onFrame = (timestamp) => {
41432
+ if (timestamp - start >= ms) {
41433
+ callback();
41434
+ return;
41435
+ }
41436
+ frame = requestAnimationFrame(onFrame);
41437
+ };
41438
+ frame = requestAnimationFrame(onFrame);
41439
+ return () => {
41440
+ cancelAnimationFrame(frame);
41441
+ };
41442
+ };
41443
+
41207
41444
  // How long the click of a released press may take to arrive. Nothing tells a
41208
41445
  // page that a release brings no click — a press that became a drag has its
41209
41446
  // click swallowed (suppressClickAfterGesture in @jsenv/dom) — so past this the
@@ -50245,7 +50482,7 @@ const usePreloadOpeningOnPress = (ref, command, prefetch = true) => {
50245
50482
  if (pointerdownEvent.button !== 0) {
50246
50483
  return;
50247
50484
  }
50248
- announceOpeningPress(element, command);
50485
+ announceOpeningPress(element, command, pointerdownEvent);
50249
50486
  };
50250
50487
  element.addEventListener("pointerdown", onPointerDown);
50251
50488
  return () => {
@@ -54421,7 +54658,9 @@ const Expandable = props => {
54421
54658
  // its own (see announceOpeningPress in commands.js) — unless aimed at a
54422
54659
  // control inside the UI part, whose click does not toggle (see onUIClick).
54423
54660
  if (pointerdownEvent.button === 0 && !openController.opened && !isAimedAtControlInsideUI(pointerdownEvent.target)) {
54424
- dispatchCustomEvent(rootRef.current, "navi_open_press");
54661
+ dispatchCustomEvent(rootRef.current, "navi_open_press", {
54662
+ event: pointerdownEvent
54663
+ });
54425
54664
  }
54426
54665
  };
54427
54666
 
@@ -54842,7 +55081,7 @@ const Expandable = props => {
54842
55081
  ,
54843
55082
  onnavi_open_press: e => {
54844
55083
  rest.onnavi_open_press?.(e);
54845
- openController.onOpeningPress?.();
55084
+ openController.onOpeningPress?.(e);
54846
55085
  },
54847
55086
  onnavi_request_close: e => {
54848
55087
  rest.onnavi_request_close?.(e);
@@ -54922,8 +55161,8 @@ const ExpandableUI = ({
54922
55161
  onnavi_request_open: e => {
54923
55162
  openController.open(e);
54924
55163
  },
54925
- onnavi_open_press: () => {
54926
- openController.onOpeningPress?.();
55164
+ onnavi_open_press: e => {
55165
+ openController.onOpeningPress?.(e);
54927
55166
  },
54928
55167
  onnavi_request_close: e => {
54929
55168
  openController.requestClose(e, {
@@ -66976,10 +67215,10 @@ const DialogStandIn = props => {
66976
67215
  }
66977
67216
  // eslint-disable-next-line react/no-unknown-property
66978
67217
  ,
66979
- onnavi_open_press: () => {
67218
+ onnavi_open_press: e => {
66980
67219
  // What the opening will read is asked for at once, as the dialog
66981
- // proper does (see open_controller.js); the dialog itself two frames
66982
- // later, so the pressed trigger is painted first — the click that
67220
+ // proper does (see open_controller.js); the dialog itself once the
67221
+ // press is taken for one (see requestBuildOnPress) — the click that
66983
67222
  // follows opens it built, or builds it itself when it comes sooner.
66984
67223
  if (signal) {
66985
67224
  preloadState(signal, readOpenValue(value));
@@ -66987,9 +67226,14 @@ const DialogStandIn = props => {
66987
67226
  if (warmRef.current) {
66988
67227
  return;
66989
67228
  }
66990
- warmRef.current = requestFrameAfterNext(() => {
66991
- warmRef.current = null;
66992
- build("press");
67229
+ warmRef.current = requestBuildOnPress(e, {
67230
+ onBuild: () => {
67231
+ warmRef.current = null;
67232
+ build("press");
67233
+ },
67234
+ onGiveUp: () => {
67235
+ warmRef.current = null;
67236
+ }
66993
67237
  });
66994
67238
  }
66995
67239
  });
@@ -67068,8 +67312,8 @@ const UncontrolledDialog = props => {
67068
67312
  // The press announcing that request (see announceOpeningPress in
67069
67313
  // commands.js), heard where the request is.
67070
67314
  ,
67071
- onnavi_open_press: () => {
67072
- openController.onOpeningPress?.();
67315
+ onnavi_open_press: e => {
67316
+ openController.onOpeningPress?.(e);
67073
67317
  },
67074
67318
  onnavi_request_close: e => {
67075
67319
  const closing = openController.requestClose(e, {
@@ -68870,8 +69114,8 @@ const UncontrolledPopover = props => {
68870
69114
  // The press announcing that request (see announceOpeningPress in
68871
69115
  // commands.js), heard where the request is.
68872
69116
  ,
68873
- onnavi_open_press: () => {
68874
- openController.onOpeningPress?.();
69117
+ onnavi_open_press: e => {
69118
+ openController.onOpeningPress?.(e);
68875
69119
  },
68876
69120
  onnavi_request_close: e => {
68877
69121
  const closing = openController.requestClose(e, {
@@ -70276,15 +70520,15 @@ const css$B = /* css */`.navi_picker_callout_dock > [data-picker-content] {
70276
70520
  display: flex;
70277
70521
  }
70278
70522
 
70523
+ &:has( > .navi_list_container:not([data-scroller], [data-overflow-visible])) {
70524
+ overflow: visible;
70525
+ }
70526
+
70279
70527
  & .navi_list_container {
70280
70528
  --list-border-radius: max(0px,
70281
70529
  var(--popover-border-radius) - var(--popover-border-width));
70282
70530
  overscroll-behavior: none;
70283
70531
  width: 100%;
70284
-
70285
- &:not([data-overflow-visible]) {
70286
- overflow: auto;
70287
- }
70288
70532
  }
70289
70533
  }
70290
70534
 
@@ -70303,15 +70547,15 @@ const css$B = /* css */`.navi_picker_callout_dock > [data-picker-content] {
70303
70547
  flex-direction: column;
70304
70548
  }
70305
70549
 
70550
+ &:has( > .navi_list_container:not([data-scroller], [data-overflow-visible])) {
70551
+ overflow: visible;
70552
+ }
70553
+
70306
70554
  & .navi_list_container {
70307
70555
  --list-border-radius: max(0px,
70308
70556
  var(--dialog-border-radius) - var(--dialog-border-width));
70309
70557
  overscroll-behavior: none;
70310
70558
  width: 100%;
70311
-
70312
- &:not([data-overflow-visible]) {
70313
- overflow: auto;
70314
- }
70315
70559
  }
70316
70560
  }
70317
70561
  `;
@@ -70757,8 +71001,8 @@ const PickerCustom = props => {
70757
71001
  },
70758
71002
  // And the press announcing that request (see announceOpeningPress in
70759
71003
  // commands.js), for the popup's content to be built on it.
70760
- "onnavi_open_press": () => {
70761
- dispatchCustomEvent(popupRef.current, "navi_open_press");
71004
+ "onnavi_open_press": e => {
71005
+ dispatchCustomEvent(popupRef.current, "navi_open_press", e.detail);
70762
71006
  },
70763
71007
  "onnavi_request_close": e => {
70764
71008
  const closing = dispatchCustomEvent(popupRef.current, "navi_request_close", e.detail);
@@ -70821,8 +71065,8 @@ const PickerCustom = props => {
70821
71065
  // The press announcing that request (see announceOpeningPress in
70822
71066
  // commands.js), heard where the request is — here too for the same
70823
71067
  // reason. Nothing to gate: the announcer asked before saying it.
70824
- onnavi_open_press: () => {
70825
- openController.onOpeningPress?.();
71068
+ onnavi_open_press: e => {
71069
+ openController.onOpeningPress?.(e);
70826
71070
  },
70827
71071
  onnavi_request_close: e => {
70828
71072
  requestInteraction({
@@ -70975,7 +71219,9 @@ const PickerCustom = props => {
70975
71219
  })) {
70976
71220
  return;
70977
71221
  }
70978
- dispatchCustomEvent(pickerEl, "navi_open_press");
71222
+ dispatchCustomEvent(pickerEl, "navi_open_press", {
71223
+ event: pointerdownEvent
71224
+ });
70979
71225
  };
70980
71226
  }
70981
71227
  let interactions = props.interactions;
@@ -73800,15 +74046,13 @@ const css$x = /* css */`@property --list-group-label-height {
73800
74046
  flex-direction: column;
73801
74047
  transition: opacity .2s;
73802
74048
  display: flex;
73803
- overflow: hidden;
73804
74049
 
73805
- &[data-borderless] {
73806
- --x-list-border-radius: 0px;
73807
- overflow: visible;
74050
+ &:not([data-borderless], [data-overflow-visible], [data-scroller]) {
74051
+ min-height: 0;
73808
74052
  }
73809
74053
 
73810
- &[data-overflow-visible="both"] {
73811
- overflow: visible;
74054
+ &[data-borderless] {
74055
+ --x-list-border-radius: 0px;
73812
74056
  }
73813
74057
 
73814
74058
  &[data-overflow-visible="x"] {