@jsenv/navi 0.29.418 → 0.29.420

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/dev/jsenv_navi.js +584 -196
  3. package/dist/dev/jsenv_navi.js.map +35 -35
  4. package/dist/jsenv_navi.js +563 -187
  5. package/dist/jsenv_navi.js.map +35 -35
  6. package/docs/AI_INSTRUCTIONS.md +177 -261
  7. package/docs/actions.md +164 -251
  8. package/docs/autofocus.md +66 -83
  9. package/docs/control_object.md +117 -214
  10. package/docs/control_value.md +121 -185
  11. package/docs/create_and_edit.md +169 -254
  12. package/docs/css_architecture.md +196 -260
  13. package/docs/data_states.md +39 -43
  14. package/docs/dialog_shape.md +91 -134
  15. package/docs/drag_interactions.md +115 -174
  16. package/docs/drag_to_travel.md +10 -9
  17. package/docs/dynamic_import.md +119 -136
  18. package/docs/error_handling.md +75 -127
  19. package/docs/field_validation.md +49 -89
  20. package/docs/form_changed.md +66 -81
  21. package/docs/group.md +141 -0
  22. package/docs/i18n.md +31 -76
  23. package/docs/interactions.md +6 -4
  24. package/docs/list_action.md +36 -51
  25. package/docs/list_refresh.md +73 -115
  26. package/docs/mobile_touch.md +56 -62
  27. package/docs/navigation.md +377 -723
  28. package/docs/network_policy.md +61 -75
  29. package/docs/pan_zoom.md +62 -103
  30. package/docs/popup_backdrop.md +128 -168
  31. package/docs/popup_lift.md +179 -268
  32. package/docs/popup_open.md +380 -651
  33. package/docs/resource.md +228 -408
  34. package/docs/route_transitions.md +333 -482
  35. package/docs/safe_area.md +102 -89
  36. package/docs/scroll.md +175 -335
  37. package/docs/state_binding.md +2 -1
  38. package/docs/testid.md +18 -23
  39. package/docs/typography.md +111 -141
  40. package/docs/view_transitions.md +106 -104
  41. package/docs/z_index.md +61 -109
  42. package/package.json +2 -2
  43. package/docs/badge_list.md +0 -83
  44. package/docs/control_group.md +0 -168
  45. package/docs/mobile_layout_pitfalls.md +0 -145
@@ -399,6 +399,22 @@ const whenRenderingResumes = (callback) => {
399
399
  renderingHold.waiting.push(callback);
400
400
  };
401
401
 
402
+ // After the render the change being applied has queued, for what depends on
403
+ // the page that change puts on screen — an offset given back to a page is
404
+ // clamped to whatever page the document holds when it is written. Under a hold
405
+ // that render runs when the hold is released, and this right after it.
406
+ // Otherwise the render is Preact's own, queued in a microtask, and every
407
+ // microtask has run by the next frame — before it paints.
408
+ const whenPageRendered = (callback) => {
409
+ if (renderingHold) {
410
+ renderingHold.waiting.push(callback);
411
+ return;
412
+ }
413
+ requestAnimationFrame(() => {
414
+ callback();
415
+ });
416
+ };
417
+
402
418
  // The hold a navigation takes on its way in — from before its first write,
403
419
  // because by the time a route announces that it matches, Preact has already
404
420
  // been told and the render is queued; a hold taken then is a hold taken too
@@ -773,7 +789,10 @@ const writeTarget = (element) => {
773
789
  silencedElement.id = addressTargetId;
774
790
  }
775
791
  window.history.replaceState(historyState, null, address);
776
- if (entry && navigation.currentEntry) {
792
+ // Only a state the entry had is put back: an entry nobody wrote one on
793
+ // (via_history.js never does) has nothing to lose, and updateCurrentEntry
794
+ // cannot say "none" — `state` is a required member, and undefined throws.
795
+ if (entry && navigation.currentEntry && entryState !== undefined) {
777
796
  navigation.updateCurrentEntry({ state: entryState });
778
797
  }
779
798
  targetWriteInProgress = false;
@@ -3527,6 +3546,8 @@ const actionWeakMap = new WeakMap();
3527
3546
  * than a first load (data_states.md). Called at the start of every run whose
3528
3547
  * value is undefined; the answer replaces it as usual, and `undefined` means
3529
3548
  * "nothing known". A resource `GET` uses it to draw the row its store holds.
3549
+ * An action declaring it is asked for it by a binding moving to new params,
3550
+ * rather than handed the previous params' answer (see inheritData).
3530
3551
  * @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
3531
3552
  * answer stays good once it has landed. Without it an answer lives exactly as
3532
3553
  * long as something references it: the screen that asked goes away, and
@@ -4184,7 +4205,14 @@ const createAction = (callback, rootOptions = {}) => {
4184
4205
  if (rejected) {
4185
4206
  return failRun(rejectedValue);
4186
4207
  }
4187
- return onRunEnd();
4208
+ // Turning the answer into a value can throw as well — a resource
4209
+ // write answering nothing to upsert. That fails the run, as the
4210
+ // catch below does when the callback answered synchronously.
4211
+ try {
4212
+ return onRunEnd();
4213
+ } catch (e) {
4214
+ return failRun(e);
4215
+ }
4188
4216
  });
4189
4217
  } catch (e) {
4190
4218
  return failRun(e);
@@ -4224,6 +4252,7 @@ const createAction = (callback, rootOptions = {}) => {
4224
4252
 
4225
4253
  const privateProperties = {
4226
4254
  valueInitial,
4255
+ provisionalValue,
4227
4256
 
4228
4257
  performRun,
4229
4258
  performReset,
@@ -4255,7 +4284,8 @@ const createAction = (callback, rootOptions = {}) => {
4255
4284
  * This enables live updates - for example, performing an HTTP GET request every time
4256
4285
  * a list of filters changes, providing real-time results without user interaction.
4257
4286
  * @param {boolean} options.inheritData - When false, each new target action starts fresh with no inherited state.
4258
- * By default (true), the proxy carries over the previous target's value and error into the new action.
4287
+ * By default (true), the proxy carries over the previous target's value and error into the new action —
4288
+ * unless the action declares a `provisionalValue`, which then says what the new one holds.
4259
4289
  * This keeps the facade in sync with the latest known data: `action.dataSignal.value` only changes when a
4260
4290
  * new action completes, not when it starts loading. Code that needs to distinguish loading state can still
4261
4291
  * check `action.runningState`, while code that just reads `action.data` always sees the most recent
@@ -4302,8 +4332,14 @@ const createActionProxyFromSignal = (
4302
4332
  let currentActionPrivateProperties = getActionPrivateProperties(action);
4303
4333
  let actionTargetPreviousWeakRef = null;
4304
4334
 
4335
+ // An action saying for itself what it holds for params it has not answered
4336
+ // (provisionalValue: a resource GET draws the row its store holds) is not
4337
+ // handed the previous params' answer — an answer about another row. What it
4338
+ // does not know stays unknown: a skeleton, never a different row.
4339
+ const inheritsAnswer =
4340
+ inheritData && !getActionPrivateProperties(action).provisionalValue;
4305
4341
  const createTarget = (params) => {
4306
- if (inheritData) {
4342
+ if (inheritsAnswer) {
4307
4343
  const previousActionTarget = actionTargetPreviousWeakRef?.deref();
4308
4344
  const previousTarget = previousActionTarget || action;
4309
4345
  return action.bindParams(params, {
@@ -4416,6 +4452,12 @@ const createActionProxyFromSignal = (
4416
4452
  return signalProxy;
4417
4453
  };
4418
4454
 
4455
+ // Params bound onto this binding make the action bound to both — these over
4456
+ // the ones it follows, as an instance's go over its action's — following the
4457
+ // signals of both: what a control does with the action it is given, and
4458
+ // actionRunEffect. Kept per params like the action's own bindings, since a
4459
+ // control binds its action again at every render.
4460
+ const bindingOnProxyWeakMap = createJsValueWeakMap();
4419
4461
  Object.assign(actionProxy, {
4420
4462
  isAction: true,
4421
4463
  isProxy: true,
@@ -4440,14 +4482,27 @@ const createActionProxyFromSignal = (
4440
4482
  });
4441
4483
  return currentAction;
4442
4484
  },
4443
- bindParams: () => {
4444
- throw new Error(
4445
- `bindParams() is not supported on action proxies, use the underlying action instead`,
4485
+ bindParams: (newParamsOrSignal, options) => {
4486
+ const existingBinding = bindingOnProxyWeakMap.get(newParamsOrSignal);
4487
+ if (existingBinding) {
4488
+ return existingBinding;
4489
+ }
4490
+ const binding = action.bindParams(
4491
+ computed(() =>
4492
+ mergeActionParams(
4493
+ paramsSignal.value,
4494
+ readParamsHandedToBind(newParamsOrSignal),
4495
+ ),
4496
+ ),
4497
+ options,
4446
4498
  );
4499
+ bindingOnProxyWeakMap.set(newParamsOrSignal, binding);
4500
+ return binding;
4447
4501
  },
4448
4502
  replaceParams: null, // Will be set below
4449
4503
  toString: () => callSourceSignal.peek(), // peek: see the action's toString
4450
4504
  meta: action.meta,
4505
+ debug: action.debug,
4451
4506
 
4452
4507
  paramsSignal: proxyParamsSignal,
4453
4508
  paramsSettlingSignal,
@@ -4590,6 +4645,29 @@ const generateActionCallSource = (name, params) => {
4590
4645
  return `${name}${argsString}`;
4591
4646
  };
4592
4647
 
4648
+ // What params handed to bindParams say at this moment, read as a binding reads
4649
+ // them (see _bindParams): a signal is its value, and so is each signal a plain
4650
+ // object holds at its first level. Read inside a computed, it follows them.
4651
+ const readParamsHandedToBind = (newParamsOrSignal) => {
4652
+ if (isSignal(newParamsOrSignal)) {
4653
+ return newParamsOrSignal.value;
4654
+ }
4655
+ if (!isPlainObject(newParamsOrSignal)) {
4656
+ return newParamsOrSignal;
4657
+ }
4658
+ const params = {};
4659
+ for (const key of Object.keys(newParamsOrSignal)) {
4660
+ const value = newParamsOrSignal[key];
4661
+ const valueSignal = isSignal(value)
4662
+ ? value
4663
+ : value
4664
+ ? value[SYMBOL_OBJECT_SIGNAL]
4665
+ : undefined;
4666
+ params[key] = valueSignal ? valueSignal.value : value;
4667
+ }
4668
+ return params;
4669
+ };
4670
+
4593
4671
  const isPlainObject = (obj) => {
4594
4672
  if (typeof obj !== "object" || obj === null) {
4595
4673
  return false;
@@ -4702,9 +4780,16 @@ const actionRunEffect = (
4702
4780
  }
4703
4781
  return params;
4704
4782
  });
4783
+ // Whether the getter asks for a run is read off what it returned, never off
4784
+ // the params of the instance it leads to: an action handed here holding
4785
+ // params of its own (an instance, a binding) keeps them when the getter
4786
+ // returns nothing — and nothing must still run nothing.
4787
+ let asking = Boolean(actionParamsSignal.peek());
4705
4788
  const actionRunnedByThisEffect = action.bindParams(actionParamsSignal, {
4706
4789
  debounce,
4707
4790
  onChange: (actionTarget, actionTargetPrevious, { explicitRunIntent }) => {
4791
+ const askingBefore = asking;
4792
+ asking = Boolean(actionParamsSignal.peek());
4708
4793
  if (explicitRunIntent) {
4709
4794
  // The caller already issued an explicit run/rerun/prerun/reset/abort —
4710
4795
  // don't attempt to also auto-run from the params change to avoid double-runs.
@@ -4715,7 +4800,7 @@ const actionRunEffect = (
4715
4800
  }
4716
4801
  if (!actionTargetPrevious && actionTarget) {
4717
4802
  // first run
4718
- if (!actionTarget.params) {
4803
+ if (!asking) {
4719
4804
  // falsy params, don't run
4720
4805
  return;
4721
4806
  }
@@ -4731,12 +4816,12 @@ const actionRunEffect = (
4731
4816
  actionTarget
4732
4817
  ) {
4733
4818
  // params changed
4734
- if (!actionTarget.params) {
4819
+ if (!asking) {
4735
4820
  // falsy params, don't run
4736
4821
  actionTargetPrevious.abort("abortOnFalsyParams");
4737
4822
  return;
4738
4823
  }
4739
- if (!actionTargetPrevious.params) {
4824
+ if (!askingBefore) {
4740
4825
  // coming from falsy-params state: action may already be cached, avoid unnecessary rerun
4741
4826
  runUnwatched(() =>
4742
4827
  actionTarget.run({ reason: "params restored from falsy state" }),
@@ -4748,7 +4833,7 @@ const actionRunEffect = (
4748
4833
  },
4749
4834
  ...options,
4750
4835
  });
4751
- if (actionParamsSignal.peek()) {
4836
+ if (asking) {
4752
4837
  runUnwatched(() =>
4753
4838
  actionRunnedByThisEffect.run({ reason: "initial truthy params" }),
4754
4839
  );
@@ -6624,6 +6709,28 @@ let navDepthMax = 0;
6624
6709
 
6625
6710
  const getNavDepth = () => navDepth;
6626
6711
 
6712
+ // Where an entry's state says it stands, undefined when it says nothing.
6713
+ const readNavDepthInState = (state) => {
6714
+ if (state && typeof state[NAV_DEPTH_STATE_KEY] === "number") {
6715
+ return state[NAV_DEPTH_STATE_KEY];
6716
+ }
6717
+ return undefined;
6718
+ };
6719
+
6720
+ // The state of the entry a document is loaded into, carrying its depth. One
6721
+ // that says a depth was written by this document (a reload in the middle of
6722
+ // the stack) and keeps it. One that says none was not: a url opened cold, the
6723
+ // first of the stack — or, rarely, an entry the browser made for a fragment,
6724
+ // reloaded, which 0 under-reports as above. Written into the entry by the
6725
+ // integrations, because a back that returns there reads where it stands from
6726
+ // the entry alone.
6727
+ const stateWithNavDepthOnLoad = (state) => {
6728
+ if (readNavDepthInState(state) !== undefined) {
6729
+ return state;
6730
+ }
6731
+ return { ...state, [NAV_DEPTH_STATE_KEY]: 0 };
6732
+ };
6733
+
6627
6734
  const applyNavigationToNavDepth = (navigationType, state) => {
6628
6735
  if (navigationType === "push") {
6629
6736
  navDepth++;
@@ -6631,10 +6738,7 @@ const applyNavigationToNavDepth = (navigationType, state) => {
6631
6738
  navDepthMax = navDepth;
6632
6739
  } else if (navigationType === "replace") ; else {
6633
6740
  // load, reload, traverse: the entry itself says where it stands.
6634
- const depthInState =
6635
- state && typeof state[NAV_DEPTH_STATE_KEY] === "number"
6636
- ? state[NAV_DEPTH_STATE_KEY]
6637
- : undefined;
6741
+ const depthInState = readNavDepthInState(state);
6638
6742
  if (depthInState !== undefined) {
6639
6743
  navDepth = depthInState;
6640
6744
  if (navDepth > navDepthMax) {
@@ -10115,6 +10219,18 @@ const preloadUrl = (url) => {
10115
10219
  }
10116
10220
  };
10117
10221
 
10222
+ // What a reload asks of each action bound to a route (see routeAction): the
10223
+ // action to run again, or nothing when its params ask nothing.
10224
+ const routeReloadMap = new WeakMap();
10225
+ const registerRouteReload = (route, reload) => {
10226
+ let reloadSet = routeReloadMap.get(route);
10227
+ if (!reloadSet) {
10228
+ reloadSet = new Set();
10229
+ routeReloadMap.set(route, reloadSet);
10230
+ }
10231
+ reloadSet.add(reload);
10232
+ };
10233
+
10118
10234
  const routeStatePreloadMap = new WeakMap();
10119
10235
  const registerRouteStatePreload = (route, preload) => {
10120
10236
  let preloadSet = routeStatePreloadMap.get(route);
@@ -10204,6 +10320,7 @@ This prevents cross-test pollution and ensures clean state.`,
10204
10320
  const updateRoutes = (
10205
10321
  url,
10206
10322
  {
10323
+ navigationType,
10207
10324
  isVisited = () => false,
10208
10325
  // state
10209
10326
  } = {},
@@ -10503,6 +10620,27 @@ This prevents cross-test pollution and ensures clean state.`,
10503
10620
  }
10504
10621
  }
10505
10622
 
10623
+ // A route's actions run again when their params change (see routeAction),
10624
+ // and a reload changes none: it asks again for the page one is on. So the
10625
+ // actions of every matching route are handed back, to be rerun — except
10626
+ // those whose params ask nothing.
10627
+ if (navigationType === "reload") {
10628
+ const reloadSet = new Set();
10629
+ for (const route of returnValue.matchingRouteSet) {
10630
+ const routeReloadSet = routeReloadMap.get(route);
10631
+ if (!routeReloadSet) {
10632
+ continue;
10633
+ }
10634
+ for (const reload of routeReloadSet) {
10635
+ const actionToRerun = reload();
10636
+ if (actionToRerun) {
10637
+ reloadSet.add(actionToRerun);
10638
+ }
10639
+ }
10640
+ }
10641
+ returnValue.reloadSet = reloadSet;
10642
+ }
10643
+
10506
10644
  return returnValue;
10507
10645
  };
10508
10646
 
@@ -10890,11 +11028,18 @@ const installScrollRestoration = () => {
10890
11028
  }
10891
11029
  };
10892
11030
 
11031
+ // Where the document lands is the last arrival's to say. A return waits for its
11032
+ // page (see restoreScrollPositionOnReturn), and an arrival decided meanwhile — a
11033
+ // page that sends the reader elsewhere as it mounts — must not be scrolled to
11034
+ // where the return was going.
11035
+ let arrivalCount = 0;
11036
+
10893
11037
  // Nothing to put back is not the same as putting back the top: a page arrived
10894
11038
  // at for the first time is startAtTop's business, and this must not step on it.
10895
11039
  // Whether there was anything, for a caller who has an answer of its own for the
10896
11040
  // page that has never been read.
10897
11041
  const restoreScrollPosition = (url) => {
11042
+ arrivalCount++;
10898
11043
  const position = positionByUrl.get(new URL(url, window.location.href).href);
10899
11044
  if (!position) {
10900
11045
  return false;
@@ -10908,6 +11053,28 @@ const restoreScrollPosition = (url) => {
10908
11053
  return true;
10909
11054
  };
10910
11055
 
11056
+ // A traversal back to `url`. The offset is written once the page returned to is
11057
+ // rendered: written sooner, the document still holds the page being left and
11058
+ // clamps it to that page's height. It is read at once all the same — a page
11059
+ // left taller than the one arriving is clamped by the render itself, and that
11060
+ // clamp is a scroll recorded under the url already returned to.
11061
+ const restoreScrollPositionOnReturn = (url) => {
11062
+ const arrival = ++arrivalCount;
11063
+ const position = positionByUrl.get(new URL(url, window.location.href).href);
11064
+ if (!position) {
11065
+ return;
11066
+ }
11067
+ whenPageRendered(() => {
11068
+ if (arrival !== arrivalCount) {
11069
+ return;
11070
+ }
11071
+ if (documentHoldCount) {
11072
+ return;
11073
+ }
11074
+ scrollTo(position);
11075
+ });
11076
+ };
11077
+
10911
11078
  // A page one arrives at for the first time starts at its top. Only a document
10912
11079
  // navigation does that on its own: a pushState creates its entry with whatever
10913
11080
  // scroll happened to be there, so without this the page opens at the offset of
@@ -10933,6 +11100,7 @@ const startAtTop = (url, { from } = {}) => {
10933
11100
  if (!isArrival(url, { from })) {
10934
11101
  return;
10935
11102
  }
11103
+ arrivalCount++;
10936
11104
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
10937
11105
  };
10938
11106
  const isArrival = (url, { from }) => {
@@ -11403,12 +11571,12 @@ const setupBrowserIntegrationViaHistory = ({
11403
11571
  isVisited,
11404
11572
  state,
11405
11573
  });
11406
- // Where the document lands, said by what kind of arrival this is. Both are
11407
- // waited for, and for the same two reasons: the page has to be there to be
11408
- // scrolled, and a picture taken before it would be of a page at its top
11409
- // (see rendering_hold.js, which is where the waiting happens). After the
11410
- // history has been written too, so the entry being left keeps the offset
11411
- // it is at.
11574
+ // Where the document lands, said by what kind of arrival this is. Never
11575
+ // before the picture of a transition is taken, which would be of a page
11576
+ // at its top (see rendering_hold.js), and after the history has been
11577
+ // written, so the entry being left keeps the offset it is at. A return
11578
+ // also waits for the page it returns to: an offset is clamped to whatever
11579
+ // page the document holds when it is written.
11412
11580
  //
11413
11581
  // A replace gets neither: it is the same place said differently — a param
11414
11582
  // settling, a state written — and moving the reader for it would throw
@@ -11419,7 +11587,7 @@ const setupBrowserIntegrationViaHistory = ({
11419
11587
  if (navigationType === "push") {
11420
11588
  whenRenderingResumes(() => startAtTop(url, { from: urlLeft }));
11421
11589
  } else if (navigationType === "traverse") {
11422
- whenRenderingResumes(() => restoreScrollPosition(url));
11590
+ restoreScrollPositionOnReturn(url);
11423
11591
  }
11424
11592
  executeWithCleanup(
11425
11593
  () => allResult,
@@ -11654,11 +11822,12 @@ const setupBrowserIntegrationViaHistory = ({
11654
11822
  const init = () => {
11655
11823
  const url = window.location.href;
11656
11824
  const stateOnEntry = window.history.state;
11657
- const state = dropGeneratedIdKeys(stateOnEntry);
11825
+ const state = stateWithNavDepthOnLoad(dropGeneratedIdKeys(stateOnEntry));
11658
11826
  if (state !== stateOnEntry) {
11659
- // The entry itself has to lose them too, not just the document state:
11660
- // getDocumentState() reads the entry back, and every state write copies
11661
- // what it finds there onto the next one.
11827
+ // The entry itself has to carry it, not just the document state:
11828
+ // getDocumentState() reads the entry back, every state write copies what
11829
+ // it finds there onto the next one, and a back returning here reads its
11830
+ // depth from it.
11662
11831
  writeHistoryEntry("replace", state, url);
11663
11832
  }
11664
11833
  handleRoutingTask(url, {
@@ -11775,7 +11944,6 @@ const applyRouting = (
11775
11944
  url,
11776
11945
  {
11777
11946
  globalAbortSignal,
11778
- abortSignal,
11779
11947
  // state
11780
11948
  navigationType,
11781
11949
  isVisited,
@@ -11787,45 +11955,36 @@ const applyRouting = (
11787
11955
  // likely because code does not uses routing at all
11788
11956
  return {};
11789
11957
  }
11790
- const {
11791
- loadSet,
11792
- reloadSet,
11793
- abortSignalMap,
11794
- routeLoadRequestedMap,
11795
- activeRouteSet,
11796
- } = updateRoutes(url, {
11958
+ // The routes' actions follow the address by themselves (see routeAction):
11959
+ // what a navigation leaves to the routing is a reload, the one that asks
11960
+ // them again with the address unchanged.
11961
+ const { reloadSet } = updateRoutes(url, {
11797
11962
  navigationType,
11798
11963
  isVisited,
11799
11964
  // state,
11800
11965
  });
11801
- if (
11802
- (!loadSet || loadSet.size === 0) &&
11803
- (!reloadSet || reloadSet.size === 0)
11804
- ) {
11966
+ if (!reloadSet || reloadSet.size === 0) {
11805
11967
  return {
11806
11968
  allResult: undefined,
11807
11969
  requestedResult: undefined,
11808
- activeRouteSet: new Set(),
11809
11970
  };
11810
11971
  }
11811
11972
  const updateActionsResult = updateActions({
11812
11973
  globalAbortSignal,
11813
- abortSignal,
11814
- runSet: loadSet,
11974
+ // Not the navigation's own signal: the next navigation aborts that one, and
11975
+ // an action whose route still matches there would be left aborted — its
11976
+ // params do not change, so nothing would run it again.
11977
+ abortSignal: new AbortController().signal,
11815
11978
  rerunSet: reloadSet,
11816
- abortSignalMap,
11817
11979
  reason,
11818
- isReplace: navigationType === "replace",
11819
11980
  });
11820
11981
  const { allResult, runningActionSet } = updateActionsResult;
11821
11982
  const pendingTaskNameArray = [];
11822
- for (const [route, routeAction] of routeLoadRequestedMap) {
11823
- if (runningActionSet.has(routeAction)) {
11824
- pendingTaskNameArray.push(`${route.relativeUrl} -> ${routeAction.name}`);
11825
- }
11983
+ for (const runningAction of runningActionSet) {
11984
+ pendingTaskNameArray.push(runningAction.name);
11826
11985
  }
11827
11986
  routingWhile(() => allResult, pendingTaskNameArray);
11828
- return { ...updateActionsResult, activeRouteSet };
11987
+ return updateActionsResult;
11829
11988
  };
11830
11989
 
11831
11990
  const setupBrowserIntegration =
@@ -11907,6 +12066,12 @@ const stopLoad = (reason = "stopLoad() called") => {
11907
12066
  browserIntegration.stop(reason);
11908
12067
  }
11909
12068
  };
12069
+ /**
12070
+ * Asks again for the page one is on, without reloading the document: the
12071
+ * actions of the routes matching the url run again (see routeAction), one that
12072
+ * failed included — what a "Retry" under a page that could not load calls.
12073
+ * The address, the history and the scroll stay as they are.
12074
+ */
11910
12075
  const reload = browserIntegration.reload;
11911
12076
  /**
11912
12077
  * Go back to the screen this document came from.
@@ -11929,11 +12094,13 @@ const reload = browserIntegration.reload;
11929
12094
  * way a screen closed over a url keeps what was written to the url while it
11930
12095
  * was open (see useNavState's leave()).
11931
12096
  * @returns {Promise<boolean>|undefined}
11932
- * When there is something to go back to: a promise resolved once the back
11933
- * has landed and been applied (`true` — the document url and state say
11934
- * where it landed), or `false` when the traversal was preempted and landed
11935
- * nowhere. `undefined` otherwise — nothing happened, or the fallback took
11936
- * the current entry's place.
12097
+ * When there is something to go back to: a promise resolved with `true`
12098
+ * once the back has landed and been applied — the document url and state
12099
+ * then say where it landed. A back preempted by another navigation, which
12100
+ * lands nowhere, resolves `false` only under the Navigation API integration
12101
+ * (see USE_NAVIGATION_API, off today): the History API does not tell it
12102
+ * apart. `undefined` otherwise — nothing happened, or the fallback took the
12103
+ * current entry's place.
11937
12104
  */
11938
12105
  const navBack = browserIntegration.navBack;
11939
12106
  const navForward = browserIntegration.navForward;
@@ -12870,10 +13037,11 @@ const isKeyboardModality = () => keyboardNavigationUsed;
12870
13037
  {
12871
13038
  // We implement :focus and :focus-visible with enriched semantics:
12872
13039
  // an element is considered focused not only when it natively has focus, but also
12873
- // when a "focus proxy" element has focus (e.g. a read-only range input delegates
12874
- // focus to a sibling span) or when a controlling element has focus (e.g. a combobox
12875
- // input with aria-controls pointing to a listbox — the listbox should appear focused
12876
- // while the input is focused).
13040
+ // when it is a control proxy whose real control has focus (the proxy is what is
13041
+ // seen, the focus is dealt to the real control — see control_proxy.js) or when
13042
+ // a controlling element has focus (e.g. a combobox input with aria-controls
13043
+ // pointing to a listbox — the listbox should appear focused while the input is
13044
+ // focused).
12877
13045
  //
12878
13046
  // We intentionally reuse the native :focus / :focus-visible names rather than
12879
13047
  // introducing new navi-specific pseudo-classes (e.g. :-navi-focus). This is a
@@ -12965,7 +13133,7 @@ const isKeyboardModality = () => keyboardNavigationUsed;
12965
13133
 
12966
13134
  // Returns true when el holds focus indirectly — either because a controlling
12967
13135
  // element (aria-controls) has focus, or because el is a proxy whose target
12968
- // is itself controlled by a focused element.
13136
+ // has focus or is itself controlled by a focused element.
12969
13137
  const hasIndirectFocus = (el, { requireFocusVisible = false } = {}) => {
12970
13138
  // No ring inheritance without a keyboard: an editable target draws its own
12971
13139
  // ring on any focus (see isMatchingFocusVisible), but propagating that ring
@@ -14476,7 +14644,11 @@ const createOpenController = (
14476
14644
  const applyChange = () => {
14477
14645
  // Recorded with the change itself: what the DOM shows is what a render
14478
14646
  // landing between the ask and the picture must draw (see `openedInDom`).
14479
- controller.openedInDom = opened;
14647
+ // An opening records it inside the change, once its content is built
14648
+ // (see open() below).
14649
+ if (!opened) {
14650
+ controller.openedInDom = false;
14651
+ }
14480
14652
  change();
14481
14653
  };
14482
14654
  const { transitionChange } = controller;
@@ -14756,15 +14928,17 @@ const createOpenController = (
14756
14928
  controller.mountContent?.();
14757
14929
  // Only now — after the content has been built, before openEffect
14758
14930
  // shows it. Dialog/Popover recompute aria-expanded and navi-hidden
14759
- // from this flag on every render, and mountContent above renders
14931
+ // from `openedInDom` on every render, and mountContent above renders
14760
14932
  // synchronously: flipping it any earlier commits an already-open DOM
14761
14933
  // (aria-expanded "true", navi-hidden gone) before openEffect has run
14762
14934
  // a single statement, so the "closed" frame it pins to transition
14763
14935
  // from is in fact the open one and the entrance animation has
14764
- // nothing to play. It also gives the content it just built the
14765
- // opening it is documented to observe — mounted while the popup
14766
- // reads as closed, told it opened right after (see
14767
- // popup_content_mount.js and use_displayed_layout_effect.js).
14936
+ // nothing to play — on every first opening that builds its content.
14937
+ // It also gives the content it just built the opening it is
14938
+ // documented to observe — mounted while the popup reads as closed,
14939
+ // told it opened right after (see popup_content_mount.js and
14940
+ // use_displayed_layout_effect.js).
14941
+ controller.openedInDom = true;
14768
14942
  controller.opened = true;
14769
14943
  // Which press it opened during, so the release of that press is not
14770
14944
  // read as somebody dismissing it (see openedDuringThisPress).
@@ -15155,7 +15329,14 @@ const useOpenPropsEffectOnOpenController = (
15155
15329
  lastRunOpenRef.current = open;
15156
15330
 
15157
15331
  if (isFirstRun) {
15158
- const mountOpenReason = open || defaultOpen;
15332
+ // "interaction" is said even over what the caller holds reading open: a
15333
+ // popup built by the request that opens it — a dialog standing in until
15334
+ // its signal said open, see DialogStandIn in dialog.jsx — mounts BECAUSE
15335
+ // of that opening.
15336
+ const mountOpenReason =
15337
+ open && defaultOpen === "interaction"
15338
+ ? "interaction"
15339
+ : open || defaultOpen;
15159
15340
  if (mountOpenReason) {
15160
15341
  // Whether this popup being open is something that just happened, or
15161
15342
  // something that was already true when the page appeared. "interaction"
@@ -21475,10 +21656,11 @@ const swipeTypeOf = (axis, pulled) => {
21475
21656
  * insists.
21476
21657
  *
21477
21658
  * So the interaction stays declared and says "refuse" in place of what it does.
21478
- * The threshold is the same one — a mouse travelling, a finger holding still, the
21479
- * first pixel inside a `data-drag-on-contact` — and at the instant the grab would
21480
- * have been acquired there is none: nothing translates, no copy is made, no
21481
- * release is answered. `refuse` is that instant.
21659
+ * The threshold is the same one — a mouse travelling a few pixels, a finger
21660
+ * holding still, or travelling those same few pixels inside a
21661
+ * `data-drag-on-contact` — and at the instant the grab would have been
21662
+ * acquired there is none: nothing translates, no copy is made, no release is
21663
+ * answered. `refuse` is that instant.
21482
21664
  *
21483
21665
  * It walks no axis, so it takes none: a box that travels and a surface that pans
21484
21666
  * both step over an element that is refusing, and a gesture starting on a locked
@@ -25089,8 +25271,9 @@ const createResourceLifecycleManager = () => {
25089
25271
  }
25090
25272
 
25091
25273
  // scopedMany auto-dependency: only rerun parent singular GET on child POST,
25092
- // and only when the parent GET previously returned the sub-resource embedded
25093
- // inside its response (detected via action._resultProperties).
25274
+ // only the GET of the owner the child was posted to, and only when that
25275
+ // GET previously returned the sub-resource embedded inside its response
25276
+ // (detected via action._resultProperties).
25094
25277
  // GET_MANY is excluded — a list of parents is not stale just because one
25095
25278
  // child item was added to one of them.
25096
25279
  scoped_many_effect: {
@@ -25111,6 +25294,15 @@ const createResourceLifecycleManager = () => {
25111
25294
  if (parentResource !== resourceScope) {
25112
25295
  continue;
25113
25296
  }
25297
+ if (
25298
+ !isOwnerReadBy(
25299
+ triggerResourceScope,
25300
+ triggeringAction,
25301
+ actionCandidate,
25302
+ )
25303
+ ) {
25304
+ break scoped_many_effect;
25305
+ }
25114
25306
  // Only rerun if the last GET response included the embedded sub-resource.
25115
25307
  if (
25116
25308
  !getActionResultProperties(actionCandidate)?.has(propertyName)
@@ -25227,6 +25419,27 @@ const isParamSubset = (parentParams, childParams) => {
25227
25419
  return true;
25228
25420
  };
25229
25421
 
25422
+ // Whether a GET of the owner read the item a scoped child's action was answered
25423
+ // for. The child's value names its owner first ([ownerKey, …], see scopedMany);
25424
+ // the GET's value is the id of the item it read. The key may be a unique key
25425
+ // rather than that id: both lead to the owner's one store of children. Values
25426
+ // are read off the signals: the child's action is still completing, and its
25427
+ // `value` property is only written once that batch ends.
25428
+ const isOwnerReadBy = (childResource, childAction, ownerGetAction) => {
25429
+ const childValue = childAction.valueSignal.peek();
25430
+ if (!Array.isArray(childValue)) {
25431
+ return false;
25432
+ }
25433
+ const [ownerKey] = childValue;
25434
+ const ownerId = ownerGetAction.valueSignal.peek();
25435
+ const { getChildStore } = childResource;
25436
+ if (!getChildStore) {
25437
+ return ownerKey === ownerId;
25438
+ }
25439
+ const childStore = getChildStore(ownerKey);
25440
+ return childStore !== undefined && childStore === getChildStore(ownerId);
25441
+ };
25442
+
25230
25443
  const paramScopeWeakSet = createIterableWeakSet();
25231
25444
  let paramScopeIdCounter = 0;
25232
25445
  const getParamScope = (params) => {
@@ -27804,6 +28017,18 @@ const useAsyncData = (promiseOrAction, {
27804
28017
 
27805
28018
  // ─── useAction ────────────────────────────────────────────────────────────────
27806
28019
 
28020
+ // subscribe() calls back at once with the value the signal holds; what these
28021
+ // subscriptions are for is what changes after.
28022
+ const subscribeToChanges = (signal, onChange) => {
28023
+ let initial = true;
28024
+ return signal.subscribe(value => {
28025
+ if (initial) {
28026
+ initial = false;
28027
+ return;
28028
+ }
28029
+ onChange(value);
28030
+ });
28031
+ };
27807
28032
  const LoadingContext = createContext(null);
27808
28033
  const actionPendingPromiseWeakMap = new WeakMap();
27809
28034
  const dismissedActionWeakSet = new WeakSet();
@@ -27848,6 +28073,15 @@ const useActionAsyncData = (action, {
27848
28073
  const runningState = action.runningStateSignal.peek();
27849
28074
  const [, setTick] = useState(0);
27850
28075
  const routePage = useContext(RoutePageContext);
28076
+ // What this render read of the action, for the subscriptions below to tell
28077
+ // a change that came between the render and them from none at all.
28078
+ const readRef = useRef(null);
28079
+ readRef.current = {
28080
+ runningState,
28081
+ data: action.dataSignal.peek(),
28082
+ params: action.paramsSignal.peek(),
28083
+ settling: action.paramsSettlingSignal.peek()
28084
+ };
27851
28085
  useEffect(() => {
27852
28086
  let stopOwingRender = null;
27853
28087
  const rerender = () => {
@@ -27875,7 +28109,12 @@ const useActionAsyncData = (action, {
27875
28109
  }
27876
28110
  setTick(n => n + 1);
27877
28111
  };
27878
- const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
28112
+ // A run under way is a new try: an error dismissed before it is not the
28113
+ // one it may end on.
28114
+ if (action.runningStateSignal.peek() === RUNNING) {
28115
+ dismissedActionWeakSet.delete(action);
28116
+ }
28117
+ const unsubscribeFromRunningState = subscribeToChanges(action.runningStateSignal, state => {
27879
28118
  if (state === RUNNING) {
27880
28119
  dismissedActionWeakSet.delete(action);
27881
28120
  }
@@ -27886,38 +28125,24 @@ const useActionAsyncData = (action, {
27886
28125
  // (a PUT upserting an item that a GET_MANY list already holds) changes the
27887
28126
  // data while this action stays COMPLETED. Subscribing here re-renders
27888
28127
  // through the same controlled path as the run state, instead of `.value`.
27889
- let dataNotificationIsInitial = true;
27890
- const unsubscribeFromData = action.dataSignal.subscribe(() => {
27891
- if (dataNotificationIsInitial) {
27892
- // subscribe() calls back synchronously with the current value
27893
- dataNotificationIsInitial = false;
27894
- return;
27895
- }
27896
- rerender();
27897
- });
28128
+ const unsubscribeFromData = subscribeToChanges(action.dataSignal, rerender);
27898
28129
  // The params say WHICH question this is, and this hook reads them: to know
27899
28130
  // there is nothing to ask for, and to start the run it owns. A binding
27900
28131
  // retargeting from no question to a question — a filter chosen, a first
27901
28132
  // character typed — is announced by nothing above: a fresh target is IDLE
27902
28133
  // holding no data, the very state the hook already sees.
27903
- let paramsNotificationIsInitial = true;
27904
- const unsubscribeFromParams = action.paramsSignal.subscribe(() => {
27905
- if (paramsNotificationIsInitial) {
27906
- paramsNotificationIsInitial = false;
27907
- return;
27908
- }
27909
- rerender();
27910
- });
28134
+ const unsubscribeFromParams = subscribeToChanges(action.paramsSignal, rerender);
27911
28135
  // A debounced binding waits before it retargets, so nothing above changes
27912
28136
  // while the delay runs — but what is on screen is already out of date.
27913
- let settlingNotificationIsInitial = true;
27914
- const unsubscribeFromParamsSettling = action.paramsSettlingSignal.subscribe(() => {
27915
- if (settlingNotificationIsInitial) {
27916
- settlingNotificationIsInitial = false;
27917
- return;
27918
- }
28137
+ const unsubscribeFromParamsSettling = subscribeToChanges(action.paramsSettlingSignal, rerender);
28138
+ // Subscribed once the browser has painted, so the action may have moved
28139
+ // since the render: a run that very render started may be over already,
28140
+ // and nothing would say so again. What the render read, against what is
28141
+ // there now — and nothing moved, nothing to draw again.
28142
+ const read = readRef.current;
28143
+ if (action.runningStateSignal.peek() !== read.runningState || action.dataSignal.peek() !== read.data || action.paramsSignal.peek() !== read.params || action.paramsSettlingSignal.peek() !== read.settling) {
27919
28144
  rerender();
27920
- });
28145
+ }
27921
28146
  return () => {
27922
28147
  unsubscribeFromRunningState();
27923
28148
  unsubscribeFromData();
@@ -33716,11 +33941,13 @@ const bandTheCutMayBeRelaxedTo = (bandBefore, bandAfter, rectAfter) => {
33716
33941
  * where they are going, at the movement's own pace, each animation at the rate
33717
33942
  * that gets it there in that time.
33718
33943
  *
33719
- * Where they visibly stand is computed from the clock THROUGH the easing curve,
33720
- * never read off the pseudo-elements: getComputedStyle on them answers with the
33721
- * un-animated value — the animated one lives on the compositor, where no
33722
- * reading from here reaches. It is the same trap as the playbackRate setter,
33723
- * which is why every rate below is handed over with updatePlaybackRate.
33944
+ * Where they visibly stand is computed from the clock THROUGH the easing curve:
33945
+ * a fraction of the way, whatever property carries it — getComputedStyle on
33946
+ * the pseudo-elements gives the animated value of each property, with nothing
33947
+ * saying how far along its keyframes that value is. The rates are handed over
33948
+ * with updatePlaybackRate: in Chromium, the playbackRate setter given a
33949
+ * negative rate puts the pictures at their start at once, and leaves them
33950
+ * there while the clock counts down.
33724
33951
  */
33725
33952
 
33726
33953
  const viewTransitionAnimations = () => {
@@ -34305,11 +34532,11 @@ const css$12 = /* css */`@property --navi-route-transition-leave {
34305
34532
  *
34306
34533
  * It is a real box, and it must be: what is photographed and clipped IS its
34307
34534
  * rectangle. So `display: contents` cannot be used on it — an element with no
34308
- * box is never captured, the movement plays on nothing and the browser aborts
34309
- * the transition. Give it the layout the pages need instead — it is a `Box`,
34310
- * so `flex`, `className`, `style` and the rest are there for that. An
34311
- * application that already has an element holding its pages can mark that one
34312
- * with `data-navi-route-transition-area` rather than nesting another.
34535
+ * box is never captured, and the movement then plays on nothing (navi warns
34536
+ * once the pictures are taken). Give it the layout the pages need instead —
34537
+ * it is a `Box`, so `flex`, `className`, `style` and the rest are there for
34538
+ * that. An application that already has an element holding its pages can mark
34539
+ * that one with `data-navi-route-transition-area` rather than nesting another.
34313
34540
  *
34314
34541
  * @type {import("ignore:preact").FunctionComponent<{ children?: any, [key: string]: any }>}
34315
34542
  */
@@ -34370,10 +34597,14 @@ const RouteTransitionArea = ({
34370
34597
  * length of the transition the root carries
34371
34598
  * `data-navi-route-transition-type="<type>"` next to
34372
34599
  * `data-navi-route-transition="forward"|"back"`, and the application's CSS
34373
- * defines the movement against the view transition pseudo-elements:
34600
+ * defines the movement against the view transition pseudo-elements, for
34601
+ * both groups: `root` when the document travels, `navi-route-transition`
34602
+ * when an area is marked — the root is then unnamed, and a rule written for
34603
+ * `(root)` alone never applies:
34374
34604
  *
34375
34605
  * :root[data-navi-route-transition-type="spin"][data-navi-route-transition="forward"] {
34376
- * &::view-transition-new(root) {
34606
+ * &::view-transition-new(root),
34607
+ * &::view-transition-new(navi-route-transition) {
34377
34608
  * animation-name: my-spin-in;
34378
34609
  * }
34379
34610
  * }
@@ -35031,7 +35262,7 @@ const beginTransition = ({
35031
35262
  // and the movement then plays on the document itself.
35032
35263
  const areaElements = document.querySelectorAll(`[${TRANSITION_AREA_ATTRIBUTE}]`);
35033
35264
  if (areaElements.length > 1) {
35034
- warnOnce("several-areas", `${areaElements.length} elements carry ${TRANSITION_AREA_ATTRIBUTE}. They all take the same view-transition-name, and a name belongs to one element at a time: the browser refuses EVERY view transition of the document while this holds. Mark the one element the pages live in.`);
35265
+ warnOnce("several-areas", `${areaElements.length} elements carry ${TRANSITION_AREA_ATTRIBUTE}. They all take the same view-transition-name, and a name belongs to one element at a time: the browser refuses EVERY route transition while this holds. Mark the one element the pages live in.`);
35035
35266
  }
35036
35267
  const areaElement = areaElements.length > 0 ? areaElements[0] : null;
35037
35268
  // Everything about to be photographed goes deaf to the pointer; the controls
@@ -35775,8 +36006,9 @@ const css$11 = /* css */`.navi_route_travel {
35775
36006
  * - how to go to a tab. The default REPLACES the current history entry
35776
36007
  * rather than pushing one: a swipe is how one browses a page, not a place one
35777
36008
  * aimed at, and three swipes back and forth must not bury the way out of the
35778
- * page under six entries. A tab pressed is the other case and pushes, which
35779
- * is what its <Link> already does.
36009
+ * page under six entries. A tab pressed goes the way its <Link> says —
36010
+ * `<Link replace>` for a row of tabs, so a press and a swipe towards the
36011
+ * same neighbour write the same history.
35780
36012
  *
35781
36013
  * Every other prop lands on the box itself (`id`, `data-testid`, `className`,
35782
36014
  * …), which is how one of these is named: a page can hold several — a row of
@@ -35791,10 +36023,11 @@ const css$11 = /* css */`.navi_route_travel {
35791
36023
  * gesture applies its change on release instead of dragging a picture that does
35792
36024
  * not exist.
35793
36025
  *
35794
- * While a travel plays, the rest of the page is taken as a picture too — this
35795
- * box asks for `view-transition-name: root` back for that time, so an
35796
- * application that opts the document out for its own transitions gets its rule
35797
- * back the moment the travel is over.
36026
+ * While a travel plays, the rest of the page is NOT taken as a picture: the
36027
+ * root's name is dropped for that time (`view-transition-name: none`), so what
36028
+ * stands around the box — a tab row beside it — stays live and answers a press
36029
+ * mid-travel. The application's own rule for the root applies again the moment
36030
+ * the travel is over.
35798
36031
  */
35799
36032
  const RouteTravel = ({
35800
36033
  routes: routesProp,
@@ -36983,9 +37216,9 @@ const writeCover = (edge, element, size) => {
36983
37216
 
36984
37217
  /**
36985
37218
  * Binds an action to a route: it runs when the route matches, with the params
36986
- * the effect reads off the address, and is aborted when the route is left.
36987
- * Its data, its wait and its failure are read by the page through
36988
- * `useAsyncData`.
37219
+ * the effect reads off the address, runs again on a `reload()`, and is aborted
37220
+ * when the route is left. Its data, its wait and its failure are read by the
37221
+ * page through `useAsyncData`.
36989
37222
  *
36990
37223
  * A page's CODE is declared the same way — the import is one more thing the
36991
37224
  * address asks for, started with the data rather than after it:
@@ -37029,18 +37262,21 @@ const routeAction = (
37029
37262
  ? routes[0].matchingSignal
37030
37263
  : anyMatchingRouteSignal(routes);
37031
37264
  const readParams = paramsEffect || (() => true);
37032
- const actionBoundToRoute = actionRunEffect(
37033
- action,
37034
- () => {
37035
- const matching = routeMatchingSignal.value;
37036
- const params = readParams();
37037
- if (!matching) {
37038
- return null;
37039
- }
37040
- return params;
37041
- },
37042
- options,
37043
- );
37265
+ const readParamsAsked = () => {
37266
+ const matching = routeMatchingSignal.value;
37267
+ const params = readParams();
37268
+ if (!matching) {
37269
+ return null;
37270
+ }
37271
+ return params;
37272
+ };
37273
+ const actionBoundToRoute = actionRunEffect(action, readParamsAsked, options);
37274
+ for (const route of routes) {
37275
+ // Asked of the route's params as they are, like the effect asks them.
37276
+ registerRouteReload(route, () =>
37277
+ untracked(readParamsAsked) ? actionBoundToRoute : null,
37278
+ );
37279
+ }
37044
37280
  if (prefetch) {
37045
37281
  for (const route of routes) {
37046
37282
  if (paramsEffect) {
@@ -38974,10 +39210,11 @@ const flushSyncRendering = (fn) => {
38974
39210
  * without the content, and the browser builds it in an idle moment after
38975
39211
  * load — so by the time anyone clicks, it is usually already there.
38976
39212
  *
38977
- * "while-opened" is the opposite end: content that must be rebuilt from
38978
- * scratch every time, because what it shows is read once at build time and can
38979
- * change while the popup is closed — an uncontrolled field seeded from a
38980
- * `defaultValue`, a form whose fresh state is its initial state.
39213
+ * "while-opened" is the opposite end: content rebuilt from scratch every
39214
+ * time, a form whose fresh state is its initial state. What kept content
39215
+ * carries into the next opening is an unsent edit — an untouched field
39216
+ * follows a new `defaultValue` on its own (see followDefaultValue in
39217
+ * ui_state_controller.js) — and this is what throws that edit away.
38981
39218
  *
38982
39219
  * On top of whichever value is picked, intent on the anchor warms the content:
38983
39220
  * a pointer entering the popup's anchor, or focus landing in it, builds the
@@ -39011,9 +39248,11 @@ const flushSyncRendering = (fn) => {
39011
39248
  * click coming after the release, the browser taking the gesture
39012
39249
  * (`pointercancel`), or the next press starting, before any of its own
39013
39250
  * handlers run: one press at a time holds a content. A keyboard opening builds
39014
- * at open time. A popup with an `onOpen` is never warmed: that callback runs
39015
- * before the content is built (see open_controller.js), and a content seeding
39016
- * itself from what it writes must be built after it.
39251
+ * at open time. A "while-opened" popup with an `onOpen` is not warmed by the
39252
+ * press: that callback runs before the content is built (see
39253
+ * open_controller.js), and a content seeding itself from what it writes must
39254
+ * be built after it. Hover and focus warming, for the other values, builds
39255
+ * ahead of `onOpen` all the same.
39017
39256
  */
39018
39257
 
39019
39258
 
@@ -39345,6 +39584,17 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
39345
39584
  decisionDeferredToRef.current = popupMounting;
39346
39585
  return;
39347
39586
  }
39587
+ if (
39588
+ (el.tagName === "DIALOG" || el.hasAttribute("popover")) &&
39589
+ !isAncestorOpen(el)
39590
+ ) {
39591
+ // Itself a closed <dialog> or [popover]: display:none by the UA's own
39592
+ // rules, which the markup says without asking the layout — asked, every
39593
+ // closed popup mounting would force a style recalculation, and a list
39594
+ // whose rows each hold a few mounts hundreds of them. The observer
39595
+ // below fires as for any element the ancestor hides.
39596
+ return;
39597
+ }
39348
39598
  if (!isDisplayedDespiteClosedAncestor(el, ancestor)) {
39349
39599
  // Ancestor is closed and took this element off screen with it — skip
39350
39600
  // now; the observeAncestorOpenState call below will fire once it
@@ -49610,12 +49860,13 @@ const positionOfCurrentIndicator = (currentIndicator, vertical) => {
49610
49860
  * does so by being NAMED, which is all the browser needs: any change played as
49611
49861
  * a view transition animates it on the same clock as everything else in that
49612
49862
  * transition. Inside a `RouteTravel` that means it follows the pages, and the
49613
- * thumb dragging them, without either of them being told about the other. It
49614
- * is named for a movement between two tabs of the row and for nothing else:
49615
- * when the pages move and the row is on one side only, the bar leaves or
49616
- * arrives with its row. For a nav made of slides (`slideContainer`) the bar
49617
- * is one element for the whole row, and it reads the travel the container
49618
- * publishes.
49863
+ * thumb dragging them, without either of them being told about the other.
49864
+ * During a route transition or a route travel it keeps the name only for a
49865
+ * movement between two tabs of the row: when the row is on one side only,
49866
+ * the bar leaves or arrives with its row. Any other view transition finds it
49867
+ * named, a picture of its own. For a nav made of slides (`slideContainer`)
49868
+ * the bar is one element for the whole row, and it reads the travel the
49869
+ * container publishes.
49619
49870
  * @param {string} [props.slideContainer] - the id of a `<SlideContainer>` these
49620
49871
  * tabs are about: each one says which slide it is (`<Link slide="…">`), the
49621
49872
  * container says which one is on screen, and pressing a tab travels there.
@@ -51447,9 +51698,9 @@ const FixedBar = ({
51447
51698
  * A page that overflows horizontally is a bug wherever it happens, and on
51448
51699
  * Chrome Android it is a catastrophic one: the layout viewport inflates to the
51449
51700
  * content and `position: fixed` centering goes with it (see
51450
- * docs/mobile_layout_pitfalls.md). The remedy there is a wrapper in
51451
- * `overflow-x: clip`, and it works — at the price of making the cause
51452
- * invisible: nothing sticks out anymore, so nothing says a fixed width, a
51701
+ * docs/safe_area.md#a-document-wider-than-the-screen). The remedy there is a
51702
+ * wrapper in `overflow-x: clip`, and it works — at the price of making the
51703
+ * cause invisible: nothing sticks out anymore, so nothing says a fixed width, a
51453
51704
  * `min-width` or an unbreakable string is still oversized. This puts the
51454
51705
  * signal back, without giving up the net.
51455
51706
  *
@@ -54124,7 +54375,8 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
54124
54375
  * `command` runs at once — the popup it sits in closes over the run, which
54125
54376
  * goes on behind. A failure then has no popup to be read in: `resetOnError`
54126
54377
  * puts the button back and the error callout is drawn on what surrounds the
54127
- * closed popup (see docs/popup_open.md#the-popup-owns-its-open-state).
54378
+ * closed popup (see
54379
+ * docs/popup_open.md#closing-when-a-button-also-runs-an-action).
54128
54380
  * @param {boolean} [actionAbortable] The person waiting may give up on this
54129
54381
  * button's action: closing the popup the run holds calls it off and goes
54130
54382
  * through, instead of being refused. For a run whose answer may never come —
@@ -63972,7 +64224,7 @@ const css$E = /* css */`
63972
64224
  the length of the movement, and the picture of the lifted node comes out
63973
64225
  empty (Chrome 153, reproduced in a bare page). The price is a page frozen
63974
64226
  and unpressable while the movement plays: under a modal wall at the
63975
- opening, and for a quarter of a second at the closing. */
64227
+ opening, and for the popup's --popup-animation-duration at the closing. */
63976
64228
  :root[data-navi-popup-lift] {
63977
64229
  /* The popup's own pace, published on the root by popup_lift.js because the
63978
64230
  ::view-transition tree hangs off it and inherits from nowhere else. */
@@ -64316,16 +64568,21 @@ const css$E = /* css */`
64316
64568
  * @param {boolean|"last-resort"|"restore"} [props.autoFocus="last-resort"] -
64317
64569
  * Where the keyboard goes when this dialog opens — one rung of the ladder in
64318
64570
  * `docs/autofocus.md`, which is what to read for the whole of it.
64319
- * - `true` — the dialog element itself takes the keyboard, whatever it holds.
64320
- * For a dialog whose content is READ before it is filled: the focus starts
64321
- * at the top of the reading order and no virtual keyboard rises over it.
64571
+ * - `true` — the dialog element itself takes the keyboard rather than the
64572
+ * first focusable it holds; something inside that asks by name
64573
+ * (`autoFocus`) still comes first. For a dialog whose content is READ
64574
+ * before it is filled: the focus starts at the top of the reading
64575
+ * order and no virtual keyboard rises over it.
64322
64576
  * - `"last-resort"` — the dialog takes the keyboard only if it holds nothing
64323
64577
  * focusable of its own.
64324
64578
  * - `"restore"` — the dialog stays out of the opening focus chain unless it
64325
64579
  * held focus when it closed.
64326
- * - `false` — the dialog element itself is never where the focus lands; the
64327
- * opening still hands the focus to what the dialog holds. Unlike
64328
- * `Popover`'s `false`, it does not leave the keyboard where it was.
64580
+ * - `false` — the dialog says nothing, and the opening still hands out the
64581
+ * focus: a `<dialog>` is focusable and the first focusable of its own
64582
+ * subtree, so it takes the keyboard as with `true`. Where the first
64583
+ * focusable is skipped (a touch device, below), a last resort inside is
64584
+ * tried instead, and nothing there leaves the keyboard where it was.
64585
+ * Unlike `Popover`'s `false`, the transfer runs.
64329
64586
  * Wherever the keyboard is a virtual one (a touch device), the surface is
64330
64587
  * already what one arrives on: a popup is read before it is reached there, so
64331
64588
  * the focus only leaves it for something that asked by name (`autoFocus` on
@@ -64359,9 +64616,9 @@ const css$E = /* css */`
64359
64616
  * is "new" or "edit X" depending on the press must know which one it is
64360
64617
  * before what it holds is rendered.
64361
64618
  * @param {(event: Event) => void} [props.onClose] - Called when the dialog
64362
- * actually closes — not preventable (see `open_controller.js`'s own
64363
- * `onRequestClose`/`onClose` distinction; `onRequestClose` is where you'd
64364
- * veto a close instead).
64619
+ * actually closes — not preventable: it is the close happening, not a
64620
+ * request. What refuses a close is navi's: a control inside still
64621
+ * running its action (see popup_busy.js for the runs that let it through).
64365
64622
  * @param {boolean|string|{id?: string, type?: "push"|"replace"}} [props.navState] -
64366
64623
  * Keeps the open state in the history entry, so a screen left and come back
64367
64624
  * to finds this popup as it was — open, and without an entrance playing: it
@@ -64379,14 +64636,17 @@ const css$E = /* css */`
64379
64636
  * @param {"always"|"idle"|"from-first-open"|"while-opened"} [props.mount] - When
64380
64637
  * `children` are built and thrown away (see popup_content_mount.js).
64381
64638
  * `"from-first-open"` (the default) builds them on the first open and keeps
64382
- * them afterwards. `"always"` builds them right away, for content something
64639
+ * them afterwards — and the dialog itself is built then too: until its first
64640
+ * request (an open, a press on what opens it, its signal saying open) it is
64641
+ * a bare `<dialog>` holding its id (see docs/popup_open.md). `"always"` builds them right away, for content something
64383
64642
  * depends on while the popup is still closed: a value read off it, fields a
64384
64643
  * surrounding form collects on submit, a size measured from outside.
64385
64644
  * `"idle"` builds them in a browser idle moment after load — "always" minus
64386
64645
  * the cost on the critical render. `"while-opened"` throws them away once
64387
64646
  * the popup has finished closing, for content whose fresh state is its
64388
- * initial state: an uncontrolled field seeded from a `defaultValue` that
64389
- * changed while the popup was closed. Intent on the anchor (pointer
64647
+ * initial state: an edit left unsent at the close does not come back at
64648
+ * the next opening (an untouched field needs none of this to show a new
64649
+ * `defaultValue` — it follows one). Intent on the anchor (pointer
64390
64650
  * entering it, focus landing in it) builds the content ahead of the click;
64391
64651
  * under `"while-opened"`, only a press on what opens the popup does (a
64392
64652
  * `--navi-open` button, a picker's trigger), never with an `onOpen`, and a
@@ -64400,11 +64660,122 @@ const Dialog = props => {
64400
64660
  ...props
64401
64661
  });
64402
64662
  }
64403
- return jsx(UncontrolledDialog, {
64663
+ return jsx(DialogStandIn, {
64404
64664
  ...props
64405
64665
  });
64406
64666
  };
64407
64667
 
64668
+ // A dialog nobody has asked for yet stands in as its bare <dialog>. That
64669
+ // element is all that reaches a closed dialog before its first opening: a
64670
+ // command finds it by id (`commandfor`) and asks it to open, a press on what
64671
+ // opens it announces itself on it (navi_open_press), --navi-toggle reads its
64672
+ // aria-expanded. The dialog proper — its open controller, its box, its focus
64673
+ // and pseudo-class wiring — is built at the first request: an open, a press
64674
+ // about to open it, its signal or its `open` saying open. A list whose rows
64675
+ // each hold a few dialogs mounts hundreds of them, and would pay for every one
64676
+ // at every mount, for openings that mostly never come. Built once, the dialog
64677
+ // stays: the next opening finds it there.
64678
+ const DialogStandIn = props => {
64679
+ const {
64680
+ ref,
64681
+ id,
64682
+ signal,
64683
+ value,
64684
+ open
64685
+ } = props;
64686
+ const ownRef = useRef();
64687
+ const dialogRef = ref || ownRef;
64688
+ // Why the dialog proper exists, once it does; never undone.
64689
+ const builtRef = useRef(null);
64690
+ const mountedRef = useRef(false);
64691
+ const isMount = !mountedRef.current;
64692
+ mountedRef.current = true;
64693
+ // Its own reading of the signal: the dialog of one card among many, told
64694
+ // apart by `value`, follows whether IT is the one open rather than every
64695
+ // write the others cause.
64696
+ const openedSignal = useMemo(() => signal ? computed(() => readOpened(signal.value, value)) : null, [signal, value]);
64697
+ const openedBySignal = openedSignal ? openedSignal.value : false;
64698
+ if (builtRef.current === null) {
64699
+ if (isMount && (needsDialogFromMount(props) || open || openedBySignal)) {
64700
+ builtRef.current = "mount";
64701
+ } else if (open || openedBySignal) {
64702
+ // Asked open while this stood in: the opening is happening now, and it
64703
+ // plays like any other (see defaultOpen "interaction").
64704
+ builtRef.current = "interaction";
64705
+ }
64706
+ }
64707
+ const [, setBuildCount] = useState(0);
64708
+ const warmRef = useRef(null);
64709
+ const build = reason => {
64710
+ if (builtRef.current !== null) {
64711
+ return;
64712
+ }
64713
+ builtRef.current = reason;
64714
+ flushSyncRendering(() => {
64715
+ setBuildCount(count => count + 1);
64716
+ });
64717
+ };
64718
+ const builtReason = builtRef.current;
64719
+ if (builtReason !== null) {
64720
+ return jsx(UncontrolledDialog, {
64721
+ ...props,
64722
+ ref: dialogRef,
64723
+ defaultOpen: builtReason === "interaction" ? "interaction" : props.defaultOpen
64724
+ });
64725
+ }
64726
+ return jsx("dialog", {
64727
+ ref: dialogRef,
64728
+ id: id,
64729
+ className: "navi_dialog"
64730
+ // eslint-disable-next-line react/no-unknown-property
64731
+ ,
64732
+ "navi-hidden": ""
64733
+ // eslint-disable-next-line react/no-unknown-property
64734
+ ,
64735
+ "navi-out-of-flow": "",
64736
+ "aria-expanded": "false",
64737
+ "data-testid": props["data-testid"]
64738
+ // eslint-disable-next-line react/no-unknown-property
64739
+ ,
64740
+ onnavi_request_open: e => {
64741
+ // Built in this same event, and asked again: the opening is the one
64742
+ // the dialog proper would have had — its source, its anchor, its value.
64743
+ build("request");
64744
+ const opened = dispatchCustomEvent(dialogRef.current, "navi_request_open", e.detail);
64745
+ if (!opened) {
64746
+ e.preventDefault();
64747
+ }
64748
+ }
64749
+ // eslint-disable-next-line react/no-unknown-property
64750
+ ,
64751
+ onnavi_open_press: () => {
64752
+ // What the opening will read is asked for at once, as the dialog
64753
+ // proper does (see open_controller.js); the dialog itself two frames
64754
+ // later, so the pressed trigger is painted first — the click that
64755
+ // follows opens it built, or builds it itself when it comes sooner.
64756
+ if (signal) {
64757
+ preloadState(signal, readOpenValue(value));
64758
+ }
64759
+ if (warmRef.current) {
64760
+ return;
64761
+ }
64762
+ warmRef.current = requestFrameAfterNext(() => {
64763
+ warmRef.current = null;
64764
+ build("press");
64765
+ });
64766
+ }
64767
+ });
64768
+ };
64769
+ // What needs the dialog proper from the start: an opening at mount, content
64770
+ // built ahead of any opening, a history entry to read, an anchor whose hover
64771
+ // or focus builds the content ahead of the click.
64772
+ const needsDialogFromMount = props => {
64773
+ if (props.defaultOpen || props.navState || props.anchor !== undefined) {
64774
+ return true;
64775
+ }
64776
+ return props.mount !== undefined && props.mount !== "from-first-open";
64777
+ };
64778
+
64408
64779
  // No openController passed: this Dialog is used declaratively (e.g. driven
64409
64780
  // by --navi-toggle/--navi-open/--navi-close commands, the `open` prop, or
64410
64781
  // `defaultOpen`) rather than owned by a parent component.
@@ -64944,10 +65315,10 @@ const useDialogProps = props => {
64944
65315
  }
64945
65316
 
64946
65317
  // Suppressed until committed below — same @starting-style-avoidance
64947
- // reasoning as popover.jsx's own openEffect (see its top comment), even
64948
- // though Dialog never needs to measure/flip anything: it still needs a
64949
- // genuinely rendered "closed" frame to transition from, not a jump
64950
- // straight from not-shown to aria-expanded="true".
65318
+ // reasoning as popover.jsx's own openEffect (see popup_css.js's top
65319
+ // comment), even though Dialog never needs to measure/flip anything: it
65320
+ // still needs a genuinely rendered "closed" frame to transition from, not a
65321
+ // jump straight from not-shown to aria-expanded="true".
64951
65322
  dialogEl.style.transitionProperty = "none";
64952
65323
  if (backdropEl) {
64953
65324
  disarmBackdropHideRef.current?.();
@@ -66024,10 +66395,11 @@ const css$D = /* css */`
66024
66395
  * @param {boolean|"last-resort"|"restore"} [props.autoFocus="last-resort"] -
66025
66396
  * Where the keyboard goes when this popover opens — one rung of the ladder in
66026
66397
  * `docs/autofocus.md`, which is what to read for the whole of it.
66027
- * - `true` — the popover element itself takes the keyboard, whatever it
66028
- * holds. For a popover whose content is READ before it is filled: the focus
66029
- * starts at the top of the reading order and no virtual keyboard rises over
66030
- * it.
66398
+ * - `true` — the popover element itself takes the keyboard rather than the
66399
+ * first focusable it holds; something inside that asks by name
66400
+ * (`autoFocus`) still comes first. For a popover whose content is READ
66401
+ * before it is filled: the focus starts at the top of the reading
66402
+ * order and no virtual keyboard rises over it.
66031
66403
  * - `"last-resort"` — the popover takes the keyboard only if it holds nothing
66032
66404
  * focusable of its own.
66033
66405
  * - `"restore"` — the popover stays out of the opening focus chain unless it
@@ -66068,9 +66440,9 @@ const css$D = /* css */`
66068
66440
  * is "new" or "edit X" depending on the press must know which one it is
66069
66441
  * before what it holds is rendered.
66070
66442
  * @param {(event: Event) => void} [props.onClose] - Called when the popover
66071
- * actually closes — not preventable (see `open_controller.js`'s own
66072
- * `onRequestClose`/`onClose` distinction; `onRequestClose` is where you'd
66073
- * veto a close instead).
66443
+ * actually closes — not preventable: it is the close happening, not a
66444
+ * request. What refuses a close is navi's: a control inside still
66445
+ * running its action (see popup_busy.js for the runs that let it through).
66074
66446
  * @param {boolean|string|{id?: string, type?: "push"|"replace"}} [props.navState] -
66075
66447
  * Keeps the open state in the history entry, so a screen left and come back
66076
66448
  * to finds this popup as it was — open, and without an entrance playing: it
@@ -66094,8 +66466,9 @@ const css$D = /* css */`
66094
66466
  * `"idle"` builds them in a browser idle moment after load — "always" minus
66095
66467
  * the cost on the critical render. `"while-opened"` throws them away once
66096
66468
  * the popup has finished closing, for content whose fresh state is its
66097
- * initial state: an uncontrolled field seeded from a `defaultValue` that
66098
- * changed while the popup was closed. Intent on the anchor (pointer
66469
+ * initial state: an edit left unsent at the close does not come back at
66470
+ * the next opening (an untouched field needs none of this to show a new
66471
+ * `defaultValue` — it follows one). Intent on the anchor (pointer
66099
66472
  * entering it, focus landing in it) builds the content ahead of the click;
66100
66473
  * under `"while-opened"`, only a press on what opens the popup does (a
66101
66474
  * `--navi-open` button, a picker's trigger), never with an `onOpen`, and a
@@ -66520,7 +66893,7 @@ const usePopoverProps = props => {
66520
66893
  });
66521
66894
 
66522
66895
  // Suppressed until the popover is actually measured/positioned below —
66523
- // see this file's top comment for why @starting-style can't drive the
66896
+ // see popup_css.js's top comment for why @starting-style can't drive the
66524
66897
  // opening transition (it needs the popover's actual resting position
66525
66898
  // already in place, which requires a layout box that only exists once
66526
66899
  // shown).
@@ -66868,7 +67241,7 @@ const usePopoverProps = props => {
66868
67241
  // frame set up above as a real rendered state (the reflow), re-enables
66869
67242
  // transitions, flips aria-expanded="true" (only then does the CSS
66870
67243
  // transition play, from that just-committed frame to the open one,
66871
- // with no @starting-style involved at all — see this file's top
67244
+ // with no @starting-style involved at all — see popup_css.js's top
66872
67245
  // comment), suppresses pointer events until it settles, and transfers
66873
67246
  // focus in. Inlined rather than a standalone function since it only
66874
67247
  // has this one call site.
@@ -67355,8 +67728,9 @@ const css$C = /* css */`@layer navi {
67355
67728
  * `"idle"` builds them in a browser idle moment after load — `"always"`
67356
67729
  * minus the cost on the critical render.
67357
67730
  * `"while-opened"` throws them away once the popup has finished closing, for
67358
- * content whose fresh state is its initial state: an uncontrolled field
67359
- * seeded from a `defaultValue` that changed while the popup was closed.
67731
+ * content whose fresh state is its initial state: an edit left unsent at
67732
+ * the close does not come back at the next opening (an untouched field
67733
+ * needs none of this to show a new `defaultValue` — it follows one).
67360
67734
  * @param {import("ignore:preact").ComponentChildren} props.children
67361
67735
  */
67362
67736
  const Popup = props => {
@@ -67655,8 +68029,9 @@ const PickerCustom = props => {
67655
68029
  open,
67656
68030
  defaultOpen,
67657
68031
  // What Escape means for this picker. "cancel" (the default) puts back the
67658
- // value the picker had at open and, for a dialog, goes back in history —
67659
- // so everything written to the url while it was open goes back too.
68032
+ // value the picker had at open and, for a dialog with an explicit id, goes
68033
+ // back in history — so everything written to the url while it was open
68034
+ // goes back too (see pickerNavType below).
67660
68035
  // "close" makes Escape say the same thing as clicking outside: keep what
67661
68036
  // was chosen, close the popup.
67662
68037
  escapeEffect = "cancel",
@@ -68310,9 +68685,10 @@ const PickerContentInsidePopup = props => {
68310
68685
  // itself, and this one does not. Popover ignores it, same as Dialog ignores
68311
68686
  // marginWithAnchor.
68312
68687
  dockedOnSmallTouchScreen,
68313
- // Same again: the dialog takes the trigger's box as a floor (and, with
68314
- // dialogMaxWidth="var(--anchor-width)", as a ceiling) — what keeps a card
68315
- // its own width once lifted. Dialog's own `sizeFromAnchor`.
68688
+ // Same again: the dialog takes the trigger's box as a floor (Dialog's own
68689
+ // `sizeFromAnchor`) and, unless a dialogMaxWidth says otherwise, as a
68690
+ // ceiling (see maxWidth below) — what keeps a card its own width once
68691
+ // lifted.
68316
68692
  dialogSizeFromAnchor,
68317
68693
  // The caller's word on the popup's own box. Written on the popup element,
68318
68694
  // where Popover/Dialog map them to their own vars, rather than as vars on
@@ -76033,10 +76409,10 @@ const useBadgeRegistry = (children, enabled) => {
76033
76409
  * list renders nothing at all. In a <Picker> this is the only placeholder
76034
76410
  * the user gets — a picker given a `ui` does not draw its own — so pass the
76035
76411
  * placeholder text: `fallback="Select skills…"`. Plain text is the right
76036
- * choice there: it reads at the picker's size, in the placeholder color the
76037
- * picker gives its empty value slot, and the box stays the same height. A
76038
- * transparent <Badge> matches the slot to the pixel instead, at the price of
76039
- * badge-sized text. See docs/badge_list.md.
76412
+ * choice there: it reads at the picker's size, in the normal text color (a
76413
+ * caller's `ui` is never greyed as a placeholder), and the box stays the same
76414
+ * height. A transparent <Badge> matches the slot to the pixel instead, at the
76415
+ * price of badge-sized text. See docs/typography.md#badgelist.
76040
76416
  * @param {boolean} [shrinkWrap]
76041
76417
  * Narrows the list down to its widest row so the last row isn't ragged.
76042
76418
  * Defaults to true inside a <Picker> — the trigger draws a border around the