@jsenv/navi 0.29.44 → 0.29.46

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.
@@ -2,7 +2,8 @@
2
2
  * AI reading this file: read ../docs/AI_INSTRUCTIONS.md for context on
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
- import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, coarsePointerSignal } from "./jsenv_navi_side_effects.js";
5
+ import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
+ export { coarsePointerSignal } from "./jsenv_navi_side_effects.js";
6
7
  import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getElementSignature, findEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, findBefore, findAfter, initFocusGroup, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
7
8
  export { contrastColor, findEvent, startDragTo } from "@jsenv/dom";
8
9
  import { signal, computed, effect, batch, useSignal } from "@preact/signals";
@@ -49,17 +50,28 @@ const css$10 = /* css */`
49
50
  --navi-z-index-control-focused: 2;
50
51
 
51
52
  /* Kept stuck while something scrolls under it: a list header, the head
52
- and foot of a side panel, a table's sticky cells. Above raised
53
- controls a control scrolling past must go under the header that
54
- pins the column it belongs to, never over it.
55
-
56
- "While stuck" is the whole condition, and a sticky element cannot read
57
- its own stuck state in CSS: List marks its parts with a navi-stuck
58
- attribute and applies this band only there (see --list-*-z-index in
59
- list.jsx). A
60
- sticky part at rest is a block in the flow with nothing passing under
61
- it; giving it this band anyway is what slices whatever a neighbouring
62
- row lets out of its box. */
53
+ and foot of a side panel, a table's sticky cells, the header and
54
+ footer of any scrolling Box. Above raised controls a control
55
+ scrolling past must go under the header that pins the column it
56
+ belongs to, never over it.
57
+
58
+ A sticky element is a positioned one, so it already wins against
59
+ everything in the flow — the band is what it takes to also win against
60
+ what the page positioned itself, which loses to DOM order otherwise
61
+ (a sticky part is written before what scrolls under it). Box applies
62
+ it by default, isolated, and lets a call site write auto back:
63
+ --box-header-z-index / --box-footer-z-index.
64
+
65
+ "While stuck" is the condition the name states, and it costs something
66
+ to ignore: a sticky part at rest is a block in the flow with nothing
67
+ passing under it, and the band there is what slices whatever a
68
+ neighbouring row lets out of its box. CSS cannot express the
69
+ condition — an element cannot read its own stuck state — so it takes
70
+ measuring, which List does (it marks its parts with a navi-stuck
71
+ attribute against its own scroller and applies the band only there,
72
+ see --list-*-z-index in list.jsx) and Box does not: a generic
73
+ scrolling area does not know what it was given to scroll, and dropping
74
+ to auto there loses to a single position: relative. */
63
75
  --navi-z-index-sticky: 10;
64
76
 
65
77
  /* Pinned to the viewport, over the whole page: FixedBar. A decade of its
@@ -17237,15 +17249,26 @@ const isSizeSpacingKey = (key) => {
17237
17249
  // "vvw"/"vvh" are navi's own: the *visual* viewport, which — unlike vw/dvw —
17238
17250
  // shrinks when the mobile virtual keyboard opens (see layout/responsive.js), so
17239
17251
  // they are what a popup meant to stay clear of the keyboard should use.
17240
- const VIEWPORT_UNIT_SIGNALS = {
17241
- vvw: visualViewportWidthSignal,
17242
- vvh: visualViewportHeightSignal,
17243
- vw: windowWidthSignal,
17244
- vh: windowHeightSignal,
17245
- dvw: windowWidthSignal,
17246
- dvh: windowHeightSignal,
17247
- };
17248
- const VIEWPORT_LENGTH_REGEX = /^(-?\d+(?:\.\d+)?)(vvw|vvh|dvw|dvh|vw|vh)$/;
17252
+ // "appw"/"apph" are the same thing narrowed to the app's own screen: identical
17253
+ // to vvw/vvh until the app declares --navi-app-max-width, and a share of that
17254
+ // width afterwards. A gap meant to read as "a small margin" must use these —
17255
+ // 3vvw on a 1500px window is a 45px gap around a 600px app.
17256
+ // Functions rather than the signals themselves: appw/apph are not a signal to
17257
+ // read but a value to compute (a signal, then a CSS var read back). Reading the
17258
+ // signal inside still registers the same dependency for a caller doing this
17259
+ // during a render.
17260
+ const VIEWPORT_UNIT_VALUES = {
17261
+ appw: getAppWidth,
17262
+ apph: getAppHeight,
17263
+ vvw: () => visualViewportWidthSignal.value,
17264
+ vvh: () => visualViewportHeightSignal.value,
17265
+ vw: () => windowWidthSignal.value,
17266
+ vh: () => windowHeightSignal.value,
17267
+ dvw: () => windowWidthSignal.value,
17268
+ dvh: () => windowHeightSignal.value,
17269
+ };
17270
+ const VIEWPORT_LENGTH_REGEX =
17271
+ /^(-?\d+(?:\.\d+)?)(appw|apph|vvw|vvh|dvw|dvh|vw|vh)$/;
17249
17272
  const resolveViewportLength = (size) => {
17250
17273
  if (typeof size !== "string") {
17251
17274
  return null;
@@ -17255,7 +17278,7 @@ const resolveViewportLength = (size) => {
17255
17278
  return null;
17256
17279
  }
17257
17280
  const [, amount, unit] = match;
17258
- return (parseFloat(amount) / 100) * VIEWPORT_UNIT_SIGNALS[unit].value;
17281
+ return (parseFloat(amount) / 100) * VIEWPORT_UNIT_VALUES[unit]();
17259
17282
  };
17260
17283
 
17261
17284
  // "3cqw"/"2cqh" — a share of the container the given element lives in, the way
@@ -19019,14 +19042,24 @@ import.meta.css = [/* css */`
19019
19042
 
19020
19043
  [data-scrollable] {
19021
19044
  overflow: var(--x-scrollable-overflow, auto);
19045
+ --box-header-z-index: var(--navi-z-index-sticky);
19046
+ --box-footer-z-index: var(--navi-z-index-sticky);
19047
+ /* The band stays inside this box: without a stacking context here, "in
19048
+ front of my body" would be read as "in front of everything on the page"
19049
+ and a header would reach past a bar or a popup — which is exactly what
19050
+ the decades in navi_z_indexes.js are there to prevent. See
19051
+ docs/z_index.md. */
19052
+ isolation: isolate;
19022
19053
 
19023
19054
  &[data-scrollable-overflow="scroll"] {
19024
19055
  --x-scrollable-overflow: scroll;
19025
19056
  }
19026
19057
 
19027
- /* box-shadow rather than a border: it draws the separation without taking
19028
- part in the layout, so a header keeps the exact height its content asks
19029
- for and nothing shifts by a pixel when the line appears. */
19058
+ /* A real border and not a box-shadow: a shadow is drawn outside the box, so
19059
+ it lands on top of whatever comes next in the painting order and loses to
19060
+ it a body painting its own background over the line that was meant to
19061
+ separate them. The border belongs to the part itself and is always
19062
+ visible; the pixel it adds shifts nothing, these parts never shrink. */
19030
19063
  /* The corners are the container's, not the part's: a header sitting at the
19031
19064
  top of a rounded box has to follow that curve or it paints square over
19032
19065
  it (a dark header in a rounded popup is where this shows). inherit and
@@ -19034,18 +19067,18 @@ import.meta.css = [/* css */`
19034
19067
  > [data-header] {
19035
19068
  position: sticky;
19036
19069
  top: 0;
19037
- z-index: 1;
19070
+ z-index: var(--box-header-z-index);
19071
+ border-bottom: 1px solid var(--navi-separator-color-default);
19038
19072
  border-top-left-radius: inherit;
19039
19073
  border-top-right-radius: inherit;
19040
- box-shadow: 0 1px 0 var(--navi-separator-color-default);
19041
19074
  }
19042
19075
  > [data-footer] {
19043
19076
  position: sticky;
19044
19077
  bottom: 0;
19045
- z-index: 1;
19078
+ z-index: var(--box-footer-z-index);
19079
+ border-top: 1px solid var(--navi-separator-color-default);
19046
19080
  border-bottom-right-radius: inherit;
19047
19081
  border-bottom-left-radius: inherit;
19048
- box-shadow: 0 -1px 0 var(--navi-separator-color-default);
19049
19082
  }
19050
19083
 
19051
19084
  &:has(> [data-body]) {
@@ -19059,8 +19092,11 @@ import.meta.css = [/* css */`
19059
19092
 
19060
19093
  > [data-header],
19061
19094
  > [data-footer] {
19095
+ /* Nothing scrolls under them here — the body does that, next to them —
19096
+ so they are back to being blocks in the flow, and stacking is not
19097
+ their business anymore. */
19062
19098
  position: static;
19063
- z-index: unset;
19099
+ z-index: auto;
19064
19100
  flex-shrink: 0;
19065
19101
  }
19066
19102
 
@@ -21546,6 +21582,564 @@ document.body.addEventListener(
21546
21582
  { capture: true },
21547
21583
  );
21548
21584
 
21585
+ const documentStateSignal = signal(null);
21586
+ const useDocumentState = () => {
21587
+ return documentStateSignal.value;
21588
+ };
21589
+ const updateDocumentState = (value) => {
21590
+ documentStateSignal.value = value;
21591
+ };
21592
+
21593
+ /**
21594
+ * A navigation is ABOUT to be applied — said before its very first write.
21595
+ *
21596
+ * Everything else a router says arrives once the change is made: a route
21597
+ * announces that it matches, an action that it is running. That is too late for
21598
+ * anyone who needs the page as it stands BEFORE, and the browser's view
21599
+ * transitions are exactly that kind of reader — the picture they keep of the
21600
+ * page being left is taken at the next frame, and a render answering a signal
21601
+ * written a moment ago is already in the DOM by then (see route_travel.jsx).
21602
+ *
21603
+ * So this is the one moment where nothing has moved yet. It is published
21604
+ * synchronously, from the top of the navigation, and whoever listens runs
21605
+ * before the URL, the visited set, or any route has changed.
21606
+ *
21607
+ * The other end is published too, and for the same kind of reader: whoever
21608
+ * held something across the change and has nobody to hand it to gets a moment
21609
+ * to let go of it that does not depend on guessing how long the change takes.
21610
+ */
21611
+
21612
+
21613
+ const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
21614
+ const [publishAfterRouting, observeAfterRouting] = createPubSub();
21615
+
21616
+ const setupBrowserIntegrationViaHistory = ({
21617
+ applyActions,
21618
+ applyRouting,
21619
+ isRouting,
21620
+ }) => {
21621
+ const { history } = window;
21622
+
21623
+ let globalAbortController = new AbortController();
21624
+ const triggerGlobalAbort = (reason) => {
21625
+ globalAbortController.abort(reason);
21626
+ globalAbortController = new AbortController();
21627
+ };
21628
+
21629
+ const dispatchActions = (params) => {
21630
+ const { requestedResult } = applyActions({
21631
+ globalAbortSignal: globalAbortController.signal,
21632
+ abortSignal: new AbortController().signal,
21633
+ ...params,
21634
+ });
21635
+ return requestedResult;
21636
+ };
21637
+ setActionDispatcher(dispatchActions);
21638
+
21639
+ const getDocumentState = () => {
21640
+ return window.history.state ? { ...window.history.state } : null;
21641
+ };
21642
+
21643
+ const historyStartAtStart = getDocumentState();
21644
+ const visitedUrlSet = historyStartAtStart
21645
+ ? new Set(historyStartAtStart.jsenv_visited_urls || [])
21646
+ : new Set();
21647
+
21648
+ // Create a signal that tracks visited URLs for reactive updates
21649
+ // Using a counter instead of the Set directly for better performance
21650
+ // Links will check isVisited() when this signal changes
21651
+ const visitedUrlsSignal = signal(0);
21652
+
21653
+ const isVisited = (url) => {
21654
+ url = new URL(url, window.location.href).href;
21655
+ return visitedUrlSet.has(url);
21656
+ };
21657
+ const markUrlAsVisited = (url) => {
21658
+ if (visitedUrlSet.has(url)) {
21659
+ return;
21660
+ }
21661
+ visitedUrlSet.add(url);
21662
+ visitedUrlsSignal.value++;
21663
+ };
21664
+
21665
+ let abortController = null;
21666
+ const handleRoutingTask = (url, options) => {
21667
+ // Before anything is written: the visited set, the URL and every route are
21668
+ // about to change, and this is the last moment the page still stands as it
21669
+ // was. And after, whichever way the change went out — so that whoever took
21670
+ // something at the first announcement has a definite place to give it back.
21671
+ publishBeforeRouting({ url, ...options });
21672
+ try {
21673
+ return applyRoutingTask(url, options);
21674
+ } finally {
21675
+ publishAfterRouting({ url, ...options });
21676
+ }
21677
+ };
21678
+
21679
+ const applyRoutingTask = (url, options) => {
21680
+ const isSameUrl = url === window.location.href;
21681
+ const {
21682
+ reason,
21683
+ navigationType, // "load", "reload", "replace", "push", "traverse"
21684
+ state,
21685
+ } = options;
21686
+
21687
+ if (navigationType === "push" || navigationType === "replace") {
21688
+ markUrlAsVisited(url);
21689
+ // undefined → inherit current state (link click, neutral navigation)
21690
+ // null → explicit reset (no nav-state keys carried over)
21691
+ // {...} → explicit state from enter()/leave(), already built from currentState
21692
+ // When state is given it's responsability of the caller to ensure it inherits document state (or not, you want it 99% of the time)
21693
+ let effectiveState;
21694
+ const sharedState = {
21695
+ jsenv_visited_urls: Array.from(visitedUrlSet),
21696
+ };
21697
+ if (state === undefined) {
21698
+ effectiveState = {
21699
+ ...(getDocumentState() || {}),
21700
+ ...sharedState,
21701
+ };
21702
+ } else if (state === null) {
21703
+ effectiveState = sharedState;
21704
+ } else if (state) {
21705
+ effectiveState = {
21706
+ ...state,
21707
+ ...sharedState,
21708
+ };
21709
+ }
21710
+ if (navigationType === "push") {
21711
+ window.history.pushState(effectiveState, null, url);
21712
+ } else {
21713
+ window.history.replaceState(effectiveState, null, url);
21714
+ }
21715
+ updateDocumentUrl(url);
21716
+ updateDocumentState(effectiveState);
21717
+ } else {
21718
+ // traverse / reload: state comes from the history entry, no push/replace needed.
21719
+ markUrlAsVisited(url);
21720
+ updateDocumentUrl(url);
21721
+ updateDocumentState(state);
21722
+ }
21723
+
21724
+ // Skip route matching for state-only changes: push/replace to the same URL
21725
+ // (e.g. useNavState updating document state without changing the route).
21726
+ // Do NOT apply for "traverse" — window.location.href is already updated by
21727
+ // the browser before the popstate handler runs, so isSameUrl is always true
21728
+ // for back/forward navigation regardless of whether the URL actually changed.
21729
+ if (
21730
+ isSameUrl &&
21731
+ (navigationType === "push" || navigationType === "replace")
21732
+ ) {
21733
+ return undefined;
21734
+ }
21735
+
21736
+ if (abortController) {
21737
+ abortController.abort(`navigating to ${url}`);
21738
+ }
21739
+ abortController = new AbortController();
21740
+ const abortSignal = abortController.signal;
21741
+ const { allResult, requestedResult } = applyRouting(url, {
21742
+ globalAbortSignal: globalAbortController.signal,
21743
+ abortSignal,
21744
+ reason,
21745
+ navigationType,
21746
+ isVisited,
21747
+ state,
21748
+ });
21749
+ executeWithCleanup(
21750
+ () => allResult,
21751
+ () => {
21752
+ abortController = undefined;
21753
+ },
21754
+ );
21755
+ return requestedResult;
21756
+ };
21757
+
21758
+ // Browser event handlers
21759
+ window.addEventListener(
21760
+ "click",
21761
+ (e) => {
21762
+ if (e.button !== 0) {
21763
+ // Ignore non-left clicks
21764
+ return;
21765
+ }
21766
+ if (e.metaKey) {
21767
+ // Ignore clicks with meta key (e.g. open in new tab)
21768
+ return;
21769
+ }
21770
+ if (e.defaultPrevented) {
21771
+ return;
21772
+ }
21773
+ const linkElement = e.target.closest("a");
21774
+ if (!linkElement) {
21775
+ return;
21776
+ }
21777
+ if (linkElement.hasAttribute("data-readonly")) {
21778
+ return;
21779
+ }
21780
+ const href = linkElement.href;
21781
+ const { isEmpty, isCurrent, isSameOrigin, isAnchor } =
21782
+ getHrefTargetInfo(href);
21783
+ if (isEmpty || !isSameOrigin) {
21784
+ // Let link to other origins be handled by the browser
21785
+ return;
21786
+ }
21787
+ if (isAnchor) {
21788
+ // Fragment navigation belongs to the browser: it owns the indicated
21789
+ // part of the document, and taking it over would cost `:target` and the
21790
+ // focus handling that come with it.
21791
+ if (isCurrent) {
21792
+ // Except this one, which the browser answers with a scroll and
21793
+ // nothing else: same pathname, same hash, so no event and no url
21794
+ // change reaches whoever is waiting on the designated element.
21795
+ rearmUrlTarget();
21796
+ }
21797
+ return;
21798
+ }
21799
+ // Nothing here declared a route, so there is nothing to route to: the
21800
+ // page is a plain document and a link in it is a plain link. Taking it
21801
+ // over anyway would push the url and then have nothing to show for it —
21802
+ // the address bar moves and the page does not (see applyRouting's own
21803
+ // "not called yet" branch, which is where that used to end up).
21804
+ if (!isRouting()) {
21805
+ return;
21806
+ }
21807
+ e.preventDefault();
21808
+ handleRoutingTask(href, {
21809
+ reason: `"click" on a[href="${href}"]`,
21810
+ navigationType: "push",
21811
+ });
21812
+ },
21813
+ { capture: true },
21814
+ );
21815
+
21816
+ window.addEventListener(
21817
+ "submit",
21818
+ () => {
21819
+ // Handle form submissions?
21820
+ // Not needed yet
21821
+ },
21822
+ { capture: true },
21823
+ );
21824
+
21825
+ window.addEventListener("popstate", (popstateEvent) => {
21826
+ const url = window.location.href;
21827
+ const state = popstateEvent.state;
21828
+ handleRoutingTask(url, {
21829
+ reason: `"popstate" event for ${url}`,
21830
+ navigationType: "traverse",
21831
+ state,
21832
+ });
21833
+ });
21834
+
21835
+ // A fragment navigation is left to the browser (see the click handler above):
21836
+ // it owns the indicated part of the document, and taking it over would cost
21837
+ // `:target` and the focus handling that come with it. The document url still
21838
+ // has to follow it — nothing else here would notice that it moved.
21839
+ window.addEventListener("hashchange", () => {
21840
+ updateDocumentUrl(window.location.href);
21841
+ });
21842
+
21843
+ const navTo = async (url, { replace, state } = {}) => {
21844
+ handleRoutingTask(url, {
21845
+ reason: `navTo called with "${url}"`,
21846
+ navigationType: replace ? "replace" : "push",
21847
+ state,
21848
+ });
21849
+ };
21850
+
21851
+ const stop = (reason = "stop called") => {
21852
+ triggerGlobalAbort(reason);
21853
+ };
21854
+
21855
+ const reload = () => {
21856
+ const url = window.location.href;
21857
+ const state = history.state;
21858
+ handleRoutingTask(url, {
21859
+ reason: "reload called",
21860
+ navigationType: "reload",
21861
+ state,
21862
+ });
21863
+ };
21864
+
21865
+ const navBack = () => {
21866
+ window.history.back();
21867
+ };
21868
+
21869
+ const navForward = () => {
21870
+ window.history.forward();
21871
+ };
21872
+
21873
+ const init = () => {
21874
+ const url = window.location.href;
21875
+ const state = history.state;
21876
+ handleRoutingTask(url, {
21877
+ reason: "routing initialization",
21878
+ navigationType: "load",
21879
+ state,
21880
+ });
21881
+ };
21882
+
21883
+ return {
21884
+ integration: "browser_history_api",
21885
+ init,
21886
+ navTo,
21887
+ stop,
21888
+ reload,
21889
+ navBack,
21890
+ navForward,
21891
+ getDocumentState,
21892
+ isVisited,
21893
+ visitedUrlsSignal,
21894
+ };
21895
+ };
21896
+
21897
+ let updateRoutes;
21898
+
21899
+ const applyActions = (params) => {
21900
+ const updateActionsResult = updateActions(params);
21901
+ const { allResult, runningActionSet } = updateActionsResult;
21902
+ const pendingTaskNameArray = [];
21903
+ for (const runningAction of runningActionSet) {
21904
+ pendingTaskNameArray.push(runningAction.name);
21905
+ }
21906
+ workingWhile(() => allResult, pendingTaskNameArray);
21907
+ return updateActionsResult;
21908
+ };
21909
+ const applyRouting = (
21910
+ url,
21911
+ {
21912
+ globalAbortSignal,
21913
+ abortSignal,
21914
+ // state
21915
+ navigationType,
21916
+ isVisited,
21917
+ reason,
21918
+ },
21919
+ ) => {
21920
+ if (!updateRoutes) {
21921
+ // .init() not called yet
21922
+ // likely because code does not uses routing at all
21923
+ return {};
21924
+ }
21925
+ const {
21926
+ loadSet,
21927
+ reloadSet,
21928
+ abortSignalMap,
21929
+ routeLoadRequestedMap,
21930
+ activeRouteSet,
21931
+ } = updateRoutes(url, {
21932
+ navigationType,
21933
+ isVisited,
21934
+ // state,
21935
+ });
21936
+ if (
21937
+ (!loadSet || loadSet.size === 0) &&
21938
+ (!reloadSet || reloadSet.size === 0)
21939
+ ) {
21940
+ return {
21941
+ allResult: undefined,
21942
+ requestedResult: undefined,
21943
+ activeRouteSet: new Set(),
21944
+ };
21945
+ }
21946
+ const updateActionsResult = updateActions({
21947
+ globalAbortSignal,
21948
+ abortSignal,
21949
+ runSet: loadSet,
21950
+ rerunSet: reloadSet,
21951
+ abortSignalMap,
21952
+ reason,
21953
+ isReplace: navigationType === "replace",
21954
+ });
21955
+ const { allResult, runningActionSet } = updateActionsResult;
21956
+ const pendingTaskNameArray = [];
21957
+ for (const [route, routeAction] of routeLoadRequestedMap) {
21958
+ if (runningActionSet.has(routeAction)) {
21959
+ pendingTaskNameArray.push(`${route.relativeUrl} -> ${routeAction.name}`);
21960
+ }
21961
+ }
21962
+ routingWhile(() => allResult, pendingTaskNameArray);
21963
+ return { ...updateActionsResult, activeRouteSet };
21964
+ };
21965
+
21966
+ const browserIntegration = setupBrowserIntegrationViaHistory({
21967
+ applyActions,
21968
+ applyRouting,
21969
+ // Routes are declared by the consumer and registered through
21970
+ // setOnAllRouteReady below, so "does this document route at all?" is only
21971
+ // answerable once that has run — hence a function, read at click time rather
21972
+ // than a value read at setup time.
21973
+ isRouting: () => Boolean(updateRoutes),
21974
+ });
21975
+
21976
+ setOnAllRouteReady((v) => {
21977
+ updateRoutes = v;
21978
+ browserIntegration.init();
21979
+ });
21980
+ setRouteIntegration(browserIntegration);
21981
+
21982
+ const navIntegratedVia = browserIntegration.integration;
21983
+ const navTo = (target, options) => {
21984
+ const url = new URL(target, window.location.href).href;
21985
+ const currentUrl = documentUrlSignal.peek();
21986
+ if (url === currentUrl) {
21987
+ if (options?.state === undefined) {
21988
+ return null;
21989
+ }
21990
+ // State-only update on same URL: skip if state is identical to current.
21991
+ const currentState = browserIntegration.getDocumentState();
21992
+ if (compareTwoJsValues(options.state, currentState)) {
21993
+ return null;
21994
+ }
21995
+ }
21996
+ return browserIntegration.navTo(url, options);
21997
+ };
21998
+ const stopLoad = (reason = "stopLoad() called") => {
21999
+ const windowIsLoading = windowIsLoadingSignal.value;
22000
+ if (windowIsLoading) {
22001
+ window.stop();
22002
+ }
22003
+ const documentIsBusy = documentIsBusySignal.value;
22004
+ if (documentIsBusy) {
22005
+ browserIntegration.stop(reason);
22006
+ }
22007
+ };
22008
+ const reload = browserIntegration.reload;
22009
+ const navBack = browserIntegration.navBack;
22010
+ const navForward = browserIntegration.navForward;
22011
+ const isVisited = browserIntegration.isVisited;
22012
+ const visitedUrlsSignal = browserIntegration.visitedUrlsSignal;
22013
+ browserIntegration.handleActionTask;
22014
+
22015
+ // Preact's own useId() (see preact/hooks) returns "P<mask0>-<mask1>", where
22016
+ // the mask is derived from render order within the nearest root/async
22017
+ // boundary — stable across re-renders of the *same* mount, but not across a
22018
+ // reload (render order can differ) or even across two mounts on the same
22019
+ // page (two components hitting useId() in the same relative order get the
22020
+ // same string). Storing one of these under type: "push" bakes it into a
22021
+ // history entry: reload the page and the entry's key may now belong to a
22022
+ // completely different component (or none), silently auto-opening whatever
22023
+ // happens to render at that same position instead.
22024
+ const PREACT_GENERATED_ID_REGEX = /^P\d+-\d+/;
22025
+ const isLikelyPreactGeneratedId = (id) => PREACT_GENERATED_ID_REGEX.test(id);
22026
+
22027
+ const NO_OP = () => {};
22028
+ const NO_ID_GIVEN = [undefined, NO_OP, NO_OP];
22029
+ const useNavStateBasic = (
22030
+ id,
22031
+ { debug, type = "replace", onLeave, defaultValue } = {},
22032
+ ) => {
22033
+ // Hooks must be called unconditionally — before the !id early return.
22034
+ const state = documentStateSignal.value;
22035
+ // Key presence is the flag — the value may be anything, including undefined.
22036
+ const keyInState = Boolean(id && state && Object.hasOwn(state, id));
22037
+ const onLeaveRef = useRef(onLeave);
22038
+ onLeaveRef.current = onLeave;
22039
+ const prevKeyInStateRef = useRef(keyInState);
22040
+ // enteredRef tracks whether enter() was called without a matching leave() yet.
22041
+ // It lets the effect distinguish an external disappearance (back button → fire onLeave)
22042
+ // from a programmatic one (leave() already set it to false before the state updates).
22043
+ const enteredRef = useRef(false);
22044
+ useEffect(() => {
22045
+ const prevKeyInState = prevKeyInStateRef.current;
22046
+ prevKeyInStateRef.current = keyInState;
22047
+ if (prevKeyInState && !keyInState && enteredRef.current) {
22048
+ enteredRef.current = false;
22049
+ onLeaveRef.current?.();
22050
+ }
22051
+ }, [keyInState]);
22052
+
22053
+ if (!id) {
22054
+ return NO_ID_GIVEN;
22055
+ }
22056
+
22057
+ let effectiveType = type;
22058
+ if (type === "push" && isLikelyPreactGeneratedId(id)) {
22059
+ effectiveType = "replace";
22060
+ }
22061
+
22062
+ const currentValue = keyInState ? state[id] : defaultValue;
22063
+
22064
+ if (debug) {
22065
+ console.debug(`useNavState(${id}) current value is ${currentValue}`);
22066
+ }
22067
+
22068
+ // enter(value): navigate TO this state (push or replace depending on type).
22069
+ // Calling enter() without a value stores "on" — the mere presence of the key
22070
+ // in the document state is enough to match; the value just allows associating
22071
+ // extra data with the entry when needed.
22072
+ const enter = (value = "on") => {
22073
+ enteredRef.current = true;
22074
+ const currentStateCopy = browserIntegration.getDocumentState() || {};
22075
+ if (Object.hasOwn(currentStateCopy, id) && currentStateCopy[id] === value) {
22076
+ return;
22077
+ }
22078
+ currentStateCopy[id] = value;
22079
+ navTo(window.location.href, {
22080
+ replace: effectiveType !== "push",
22081
+ state: currentStateCopy,
22082
+ });
22083
+ };
22084
+
22085
+ // leave(): navigate AWAY FROM this state (navBack in push mode, replace in replace mode).
22086
+ // isBack: when true (cancel close in push mode), call history.back() to restore the
22087
+ // pre-open state — discards any in-progress edits.
22088
+ // When false (confirmed close), replace the pushed entry instead: preserves the
22089
+ // current URL state (e.g. a new picker value) while removing the popup key.
22090
+ const leave = ({ isBack } = {}) => {
22091
+ enteredRef.current = false;
22092
+ const currentStateCopy = browserIntegration.getDocumentState() || {};
22093
+ if (!Object.hasOwn(currentStateCopy, id)) {
22094
+ return;
22095
+ }
22096
+ if (effectiveType === "push" && isBack) {
22097
+ browserIntegration.navBack();
22098
+ } else {
22099
+ delete currentStateCopy[id];
22100
+ navTo(window.location.href, {
22101
+ replace: true,
22102
+ state: currentStateCopy,
22103
+ });
22104
+ }
22105
+ };
22106
+
22107
+ return [currentValue, enter, leave];
22108
+ };
22109
+
22110
+ /**
22111
+ * Stores a named value in the browser's document state and returns it reactively.
22112
+ * The component re-renders whenever the value changes (navigation, back/forward button).
22113
+ *
22114
+ * @param {string} id
22115
+ * Unique key used to store the value in document state. Must be stable across renders.
22116
+ *
22117
+ * @param {object} [options]
22118
+ * @param {"push"|"replace"} [options.type="replace"]
22119
+ * Controls how enter() adds the state to browser history.
22120
+ * - "push": creates a new history entry — pressing the back button removes it and calls onLeave.
22121
+ * - "replace": updates the current history entry — no extra history entry is created.
22122
+ * Silently downgraded to "replace" (with a dev-only console.warn) when `id`
22123
+ * looks auto-generated (e.g. preact's own useId()) — an unstable id baked
22124
+ * into a pushed history entry won't survive a reload correctly, and could
22125
+ * even collide with a different component's own auto-generated id. Pass a
22126
+ * stable, explicit id to actually get "push" behavior.
22127
+ * @param {() => void} [options.onLeave]
22128
+ * Called when the state key disappears **externally** — e.g. the user presses the browser
22129
+ * back button. Not called when leave() is invoked programmatically.
22130
+ * @param {*} [options.defaultValue]
22131
+ * Value returned when `id` is absent from document state. Defaults to `undefined`.
22132
+ *
22133
+ * @returns {[value, enter, leave]}
22134
+ * - `value`: current value from document state, or `defaultValue` when the key is absent.
22135
+ * - `enter(value = "on")`: navigate TO this state (stores `value` under `id`).
22136
+ * Calling without an argument stores `"on"` — the presence of the key is enough to match;
22137
+ * the value allows associating extra data when needed.
22138
+ * - `leave()`: navigate AWAY FROM this state (removes `id` from document state,
22139
+ * or goes back in history when `type` is "push").
22140
+ */
22141
+ const useNavState = useNavStateBasic;
22142
+
21549
22143
  /**
21550
22144
  * @param {Element} element The element asking — the command's source, and the
21551
22145
  * anchor a popup opens on unless `anchor` says otherwise.
@@ -21953,15 +22547,21 @@ registerNaviCommand("--navi-send", (source, event) => {
21953
22547
  requester = firstButtonSubmitting;
21954
22548
  }
21955
22549
  }
21956
- // Read here rather than above: it depends on the requester, which is only
21957
- // known now — Enter in a field sends through the first submit button, and
21958
- // what follows the send is that button's answer.
21959
- const afterSend = resolveAfterSend(target, requester);
21960
22550
  // Nothing is committed when a constraint fails, so nothing is decided
21961
22551
  // and the popup must stay open — with the form still in front of the
21962
22552
  // user, showing what it is waiting for.
21963
22553
  let invalid = false;
22554
+ // What follows the send is read at the moment it runs, never before it:
22555
+ // it depends on the requester (Enter in a field sends through the first
22556
+ // submit button, and what follows is that button's answer), and on
22557
+ // anything the send itself decided — an action that learned where to go
22558
+ // from the response writes it on the form while it runs
22559
+ // (data-after-send), and this is what picks it up.
21964
22560
  const runAfterSend = () => {
22561
+ const afterSend = resolveAfterSend(target, requester);
22562
+ if (!afterSend) {
22563
+ return;
22564
+ }
21965
22565
  triggerNaviCommand(source, afterSend, event, { optional: true });
21966
22566
  };
21967
22567
  const {
@@ -21996,7 +22596,7 @@ registerNaviCommand("--navi-send", (source, event) => {
21996
22596
  requester,
21997
22597
  }),
21998
22598
  );
21999
- if (sent === false || invalid || !afterSend) {
22599
+ if (sent === false || invalid) {
22000
22600
  return sent;
22001
22601
  }
22002
22602
  if (isRunning) {
@@ -22188,6 +22788,30 @@ registerNaviCommand("--navi-back", (source, event) => {
22188
22788
  };
22189
22789
  });
22190
22790
 
22791
+ // Where a press takes the user. The destination is the command's argument
22792
+ // because it says WHAT the command does — "--navi-nav-to:/games/42" — which is how
22793
+ // it can also be what follows a form submission: the form has answered its
22794
+ // question, and the answer to "what now" is a page.
22795
+ //
22796
+ // A destination fixed at the call site, so it is for a page known before the
22797
+ // send — which is what a form needs, since it must also know where to go when
22798
+ // the press had nothing to send. A destination the response decides (a
22799
+ // creation, whose id comes back with it) is the action's own business: it
22800
+ // navigates itself.
22801
+ registerNaviCommand("--navi-nav-to", (source, event, { argument }) => {
22802
+ if (!argument) {
22803
+ console.warn(
22804
+ `[navi] "--navi-nav-to" needs a destination: --navi-nav-to:/the/url (relative to the current page, or absolute).`,
22805
+ );
22806
+ return undefined;
22807
+ }
22808
+ const target = resolveExplicitTarget(source) || source;
22809
+ return {
22810
+ target,
22811
+ implementation: () => navTo(argument),
22812
+ };
22813
+ });
22814
+
22191
22815
  registerNaviCommand("--navi-toggle", (source, event, { anchor } = {}) => {
22192
22816
  const target =
22193
22817
  resolveExplicitTarget(source) || resolveClosestExpandable(source);
@@ -24272,6 +24896,7 @@ const useUIGroupStateController = (
24272
24896
  pendingChangeRef.current = null;
24273
24897
  const batchedEvent = new CustomEvent(
24274
24898
  `${controlType}_batched_ui_state_update`,
24899
+ { detail: {} },
24275
24900
  );
24276
24901
  chainEvent(batchedEvent, pendingChange.e);
24277
24902
  scope._onChange(batchedEvent, {
@@ -24841,6 +25466,13 @@ const useControlProps = (props, {
24841
25466
  const onButtonInteractionAllowed = e => {
24842
25467
  triggerUIAction(e);
24843
25468
  const control = ref.current;
25469
+ if (!control) {
25470
+ // What the button just did took the button away: a command that
25471
+ // navigates, a popup closing over it. There is no control left to
25472
+ // ask for an action, and nothing is lost by not asking — what the
25473
+ // press was for has already happened.
25474
+ return;
25475
+ }
24844
25476
  tryActionAfterInteractionAllowed(control, {
24845
25477
  event: e,
24846
25478
  action: boundAction,
@@ -28218,15 +28850,26 @@ const css$V = /* css */`
28218
28850
 
28219
28851
  Capping the *size* here rather than only offsetting the position is
28220
28852
  what makes a centered dialog follow the mobile virtual keyboard for
28221
- free: --navi-vvw/--navi-vvh track the visual viewport, so the browser
28222
- reflows the dialog itself as the keyboard opens. */
28223
- --x-dialog-container-spacing: 3vvw;
28224
-
28853
+ free: --navi-app-width/--navi-app-height track the visual viewport, so
28854
+ the browser reflows the dialog itself as the keyboard opens.
28855
+
28856
+ A share of the app's own screen, not of the window (hence
28857
+ --navi-app-width rather than 3vvw): the gap must read as a small
28858
+ margin around the dialog, and 3% of a 1500px window is a 45px gap
28859
+ around a 600px app. Identical to 3vvw until the app declares
28860
+ --navi-app-max-width. */
28861
+ --x-dialog-container-spacing: calc(0.03 * var(--navi-app-width));
28862
+
28863
+ /* --navi-app-width, not --navi-vvw: a top-layer dialog is calibrated on
28864
+ the app's own screen, which is the viewport unless the app declared a
28865
+ narrower one (see navi_css_vars.js). An app-width cap alone never
28866
+ costs the gap below — it is subtracted from whichever of the two ends
28867
+ up smaller. */
28225
28868
  --dialog-maxmax-width: calc(
28226
- var(--navi-vvw) - 2 * var(--x-dialog-container-spacing)
28869
+ var(--navi-app-width) - 2 * var(--x-dialog-container-spacing)
28227
28870
  );
28228
28871
  --dialog-maxmax-height: calc(
28229
- var(--navi-vvh) - 2 * var(--x-dialog-container-spacing)
28872
+ var(--navi-app-height) - 2 * var(--x-dialog-container-spacing)
28230
28873
  );
28231
28874
 
28232
28875
  --dialog-border-radius: var(--navi-popup-border-radius);
@@ -28558,17 +29201,19 @@ const css$V = /* css */`
28558
29201
  * shown via the non-modal `.show()` instead, staying in normal document
28559
29202
  * flow inside its own positioned ancestor — confined to (and clipped by)
28560
29203
  * that container instead of the whole viewport.
28561
- * @param {boolean} [props.dockedOnTouch] - Turns the dialog into a bottom sheet
28562
- * (docked flush to the bottom edge, full width) when the pointer is coarse,
28563
- * and leaves it alone otherwise. For a dialog meant to be interacted with
28564
- * rather than merely read: under a finger the keyboard owns the bottom of
28565
- * the screen and a centered box ends up both cramped and out of thumb
28566
- * reach, while under a mouse the centered box is already the right shape
28567
- * hence a prop that only ever does something on touch. It supplies defaults
28568
- * for `positionArea`, `marginWithContainer`, `expandX` and `scrollCapture`,
28569
- * so any of them can still be pinned explicitly. Keyed off `(pointer: coarse)` (the
28570
- * input device, not a width breakpoint — a narrow desktop window is still a
28571
- * mouse) via `coarsePointerSignal`, so it re-resolves live.
29204
+ * @param {boolean} [props.dockedOnSmallTouchScreen] - Turns the dialog into a
29205
+ * bottom sheet (docked flush to the bottom edge, full width) on a small touch
29206
+ * screen, and leaves it alone otherwise. For a dialog meant to be interacted
29207
+ * with rather than merely read: on a phone the keyboard owns the bottom of
29208
+ * the screen and a centered box ends up both cramped and out of thumb reach,
29209
+ * while under a mouse the centered box is already the right shape. Both
29210
+ * halves of the name matter (`smallTouchScreenSignal`): touch alone would
29211
+ * dock a big touch screen — a tablet, a kiosk panel — a whole screen away
29212
+ * from where the finger just tapped, and size alone would dock a narrow
29213
+ * desktop window, which is still a mouse. It supplies defaults for
29214
+ * `positionArea`, `marginWithContainer`, `expandX` and `scrollCapture`, so
29215
+ * any of them can still be pinned explicitly. Re-resolves live as the pointer
29216
+ * type or the window size changes.
28572
29217
  * @param {string} [props.positionArea="center"] - Where to dock the dialog
28573
29218
  * within its container (the viewport for `layer="top"`, the positioned
28574
29219
  * ancestor for `layer="local"`) — Dialog is never anchored to a real
@@ -28581,17 +29226,19 @@ const css$V = /* css */`
28581
29226
  * `inset(top)`) for the overlapping variant.
28582
29227
  * @param {boolean} [props.expand] - Shorthand for both `expandX` and `expandY`.
28583
29228
  * @param {boolean} [props.expandX] - Stretches the dialog to the full width its
28584
- * container allows (`--dialog-maxmax-width`). Set by `dockedOnTouch` on a
28585
- * touch device.
29229
+ * container allows (`--dialog-maxmax-width`). Set by
29230
+ * `dockedOnSmallTouchScreen` on a small touch screen.
28586
29231
  * @param {boolean} [props.expandY] - Same, vertically
28587
29232
  * (`--dialog-maxmax-height`).
28588
- * @param {string|number} [props.marginWithContainer="3vvw"] - Minimum gap kept
29233
+ * @param {string|number} [props.marginWithContainer="3appw"] - Minimum gap kept
28589
29234
  * between the dialog and the edges of its container, whatever its
28590
29235
  * `positionArea`: it both caps the dialog's own size (via
28591
29236
  * `--x-dialog-container-spacing`, written from this prop) and offsets a docked
28592
29237
  * one from the edge it docks to. Accepts a spacing token ("s", "m"…), a
28593
- * number of pixels, or a viewport length — "vvw"/"vvh" being the visual
28594
- * viewport, which shrinks when the mobile keyboard opens. Pass 0 for a dialog
29238
+ * number of pixels, or a viewport length — "appw"/"apph" being the app's own
29239
+ * screen (the visual viewport, or the narrower one the app declared with
29240
+ * --navi-app-max-width) and "vvw"/"vvh" the visual viewport itself, which
29241
+ * shrinks when the mobile keyboard opens. Pass 0 for a dialog
28595
29242
  * meant to sit flush (a side panel).
28596
29243
  * @param {"close"|"cancel"|"capture"|"none"} [props.pointerInteractionOutsideEffect="close"]
28597
29244
  * - `"close"` closes the dialog on an outside click. `"capture"`/`"none"`
@@ -28610,7 +29257,7 @@ const css$V = /* css */`
28610
29257
  * A `layer="local"` dialog always locks its own positioned ancestor's
28611
29258
  * scroll while open (its backdrop only covers the scrollport, so scrolling
28612
29259
  * there would reveal uncovered content); this prop extends the lock to the
28613
- * whole page. Defaults to `true` for a dialog docked by `dockedOnTouch`.
29260
+ * whole page. Defaults to `true` for a dialog docked by `dockedOnSmallTouchScreen`.
28614
29261
  * @param {boolean|"auto"|"fading"|"scaling"|"sliding"|`slide-from-${string}`} [props.animation]
28615
29262
  * - `true`/`"auto"` resolves to `"scaling"` for a centered `positionArea`,
28616
29263
  * or a concrete `"slide-from-*"` direction otherwise. Any other explicit
@@ -28793,10 +29440,10 @@ const DialogLocal = props => {
28793
29440
  * contentProps]` — `backdropProps` is `null` for the via-attribute renderer
28794
29441
  * (its own backdrop is native, not a real element).
28795
29442
  */
28796
- // What a dialog turns into under a finger. "bottom" is not a taste: it puts
28797
- // the dialog in the zone a handheld device is actually operated from — where
28798
- // the thumbs rest and where the virtual keyboard comes up — instead of the
28799
- // middle of the screen, which is the farthest point from both.
29443
+ // What a dialog turns into on a small touch screen. "bottom" is not a taste:
29444
+ // it puts the dialog in the zone a phone is actually operated from — where the
29445
+ // thumbs rest and where the virtual keyboard comes up — instead of the middle
29446
+ // of the screen, which is the farthest point from both.
28800
29447
  // Only defaults: an explicitly passed prop still wins, so the docked shape can
28801
29448
  // be adjusted one axis at a time instead of being all-or-nothing.
28802
29449
  const DOCKED = {
@@ -28834,7 +29481,7 @@ const useDialogProps = props => {
28834
29481
  // .show() instead, staying in normal document flow, position: absolute
28835
29482
  // relative to its own positioned ancestor. See this file's top comment.
28836
29483
  layer = "top",
28837
- dockedOnTouch,
29484
+ dockedOnSmallTouchScreen,
28838
29485
  // Same grammar as Popover's own positionArea — see this file's top
28839
29486
  // comment and popup_shared.js's parsePositionArea.
28840
29487
  positionArea: positionAreaProp,
@@ -28887,16 +29534,18 @@ const useDialogProps = props => {
28887
29534
  });
28888
29535
  const isModal = layer === "top";
28889
29536
  const ref = props.ref;
28890
- // Only touch changes anything: with a mouse a dialog already wants to be the
28891
- // centered box it is by default, so there is nothing to resolve there.
28892
- const isDocked = dockedOnTouch && coarsePointerSignal.value;
29537
+ // Only a small touch screen changes anything: on a mouse and on a touch
29538
+ // screen too big to reach the bottom edge of a dialog already wants to be
29539
+ // the centered box it is by default, so there is nothing to resolve.
29540
+ const isDocked = dockedOnSmallTouchScreen && smallTouchScreenSignal.value;
28893
29541
  const positionArea = positionAreaProp ?? (isDocked ? DOCKED.positionArea : "center");
28894
29542
  const marginWithContainer = marginWithContainerProp ?? (isDocked ? DOCKED.marginWithContainer :
28895
- // A share of whatever holds the dialog: the viewport for a top-layer
28896
- // one — where vvw is exactly "3% of the container", the container being
28897
- // the viewport and the positioned ancestor for a local one, where
28898
- // reading 3% of the viewport gives an absurd gap inside a small box.
28899
- isModal ? "3vvw" : "3cqw");
29543
+ // A share of whatever holds the dialog: the app's own screen for a
29544
+ // top-layer one — where appw is exactly "3% of the container", the
29545
+ // container being that screen (the viewport, unless the app declared a
29546
+ // narrower one) and the positioned ancestor for a local one, where
29547
+ // reading 3% of the screen gives an absurd gap inside a small box.
29548
+ isModal ? "3appw" : "3cqw");
28900
29549
  // "expand || expandX", the shorthand semantics Popup used to apply before
28901
29550
  // handing them over — the docked default only applies when neither was said
28902
29551
  const expandXUnset = expand === undefined && expandXProp === undefined;
@@ -29133,7 +29782,7 @@ const useDialogProps = props => {
29133
29782
  // A value only CSS could evaluate (a spacing token resolving to a var(),
29134
29783
  // a percentage…) — the placement below needs a real number, and letting
29135
29784
  // it through would put the dialog at NaN.
29136
- console.warn(`Dialog: marginWithContainer="${marginWithContainer}" cannot be resolved to pixels. Use a number, a viewport length ("3vvw", "2vvh") or a container length ("3cqw", "2cqh").`);
29785
+ console.warn(`Dialog: marginWithContainer="${marginWithContainer}" cannot be resolved to pixels. Use a number, a viewport length ("3appw", "3vvw", "2vvh") or a container length ("3cqw", "2cqh").`);
29137
29786
  marginWithContainerInPixels = 0;
29138
29787
  }
29139
29788
  // The size caps read the same gap in CSS as the placement below applies
@@ -29560,8 +30209,10 @@ const css$U = /* css */`
29560
30209
  rather than a value so an outer component can bridge its own prop into
29561
30210
  --popover-max-height without having to restate 300px (see picker). */
29562
30211
  --popover-max-height-default: 300px;
29563
- --popover-maxmax-height: calc(0.95 * var(--navi-vvh));
29564
- --popover-maxmax-width: calc(0.95 * var(--navi-vvw));
30212
+ /* --navi-app-*, not --navi-vvw/vvh: the app's own screen, which is the
30213
+ viewport unless the app declared a narrower one (navi_css_vars.js). */
30214
+ --popover-maxmax-height: calc(0.95 * var(--navi-app-height));
30215
+ --popover-maxmax-width: calc(0.95 * var(--navi-app-width));
29565
30216
 
29566
30217
  --popover-box-shadow: var(--navi-popup-box-shadow);
29567
30218
  --popover-border-radius: var(--navi-popup-border-radius);
@@ -31029,8 +31680,8 @@ const css$T = /* css */`
31029
31680
  * @property {"close"|"cancel"|"capture"|"none"} [pointerInteractionOutsideEffect]
31030
31681
  * - What a click outside does. `"capture"`/`"none"` force an explicit answer
31031
31682
  * by refusing to treat a click elsewhere as one.
31032
- * @property {boolean} [dockedOnTouch] - `"dialog"` mode only: turn the popup
31033
- * into a bottom sheet under a finger.
31683
+ * @property {boolean} [dockedOnSmallTouchScreen] - `"dialog"` mode only: turn
31684
+ * the popup into a bottom sheet on a small touch screen.
31034
31685
  * @property {(params: { message: import("ignore:preact").ComponentChildren }) => import("ignore:preact").ComponentChildren} [renderContent]
31035
31686
  * - Replaces the popup body — the question and the two buttons — for every
31036
31687
  * confirmation at once. The per-button `confirmPopupContent` prop is the same
@@ -31048,7 +31699,7 @@ const confirmPopupOptions = {
31048
31699
  animationDuration: undefined,
31049
31700
  positionArea: undefined,
31050
31701
  pointerInteractionOutsideEffect: "close",
31051
- dockedOnTouch: false,
31702
+ dockedOnSmallTouchScreen: false,
31052
31703
  renderContent: undefined
31053
31704
  };
31054
31705
 
@@ -31130,7 +31781,7 @@ const ConfirmPopup = ({
31130
31781
  animationDuration,
31131
31782
  positionArea,
31132
31783
  pointerInteractionOutsideEffect,
31133
- dockedOnTouch,
31784
+ dockedOnSmallTouchScreen,
31134
31785
  renderContent
31135
31786
  } = confirmPopupOptions;
31136
31787
 
@@ -31166,7 +31817,7 @@ const ConfirmPopup = ({
31166
31817
  if (mode === "dialog") {
31167
31818
  return jsx(Dialog, {
31168
31819
  className: "navi_confirm_popup",
31169
- dockedOnTouch: dockedOnTouch,
31820
+ dockedOnSmallTouchScreen: dockedOnSmallTouchScreen,
31170
31821
  ...popupProps,
31171
31822
  children: body
31172
31823
  });
@@ -31421,6 +32072,16 @@ const debounceSignal = (
31421
32072
  * The action will not fire while the user is actively changing filters; it fires once
31422
32073
  * they pause for half a second.
31423
32074
  */
32075
+ // The run is not awaited here, and a rejection nobody waits for is an unhandled
32076
+ // one — in dev, an error overlay thrown over a page that is already saying what
32077
+ // went wrong. Nothing is lost by dropping it: the failure is held by the action
32078
+ // itself, and whoever reads it (useAsyncData, <Button action>) is what shows it.
32079
+ const runUnwatched = (result) => {
32080
+ if (result && typeof result.catch === "function") {
32081
+ result.catch(() => {});
32082
+ }
32083
+ };
32084
+
31424
32085
  const actionRunEffect = (
31425
32086
  action,
31426
32087
  deriveActionParamsFromSignals,
@@ -31470,7 +32131,7 @@ const actionRunEffect = (
31470
32131
  // falsy params, don't run
31471
32132
  return;
31472
32133
  }
31473
- actionTarget.run({ reason: "truthy params first run" });
32134
+ runUnwatched(actionTarget.run({ reason: "truthy params first run" }));
31474
32135
  return;
31475
32136
  }
31476
32137
 
@@ -31487,16 +32148,20 @@ const actionRunEffect = (
31487
32148
  }
31488
32149
  if (!actionTargetPrevious.params) {
31489
32150
  // coming from falsy-params state: action may already be cached, avoid unnecessary rerun
31490
- actionTarget.run({ reason: "params restored from falsy state" });
32151
+ runUnwatched(
32152
+ actionTarget.run({ reason: "params restored from falsy state" }),
32153
+ );
31491
32154
  } else {
31492
- actionTarget.rerun({ reason: "params modified" });
32155
+ runUnwatched(actionTarget.rerun({ reason: "params modified" }));
31493
32156
  }
31494
32157
  }
31495
32158
  },
31496
32159
  ...options,
31497
32160
  });
31498
32161
  if (actionParamsSignal.peek()) {
31499
- actionRunnedByThisEffect.run({ reason: "initial truthy params" });
32162
+ runUnwatched(
32163
+ actionRunnedByThisEffect.run({ reason: "initial truthy params" }),
32164
+ );
31500
32165
  }
31501
32166
  return actionRunnedByThisEffect;
31502
32167
  };
@@ -34620,6 +35285,24 @@ const TYPE_CONVERTERS = {
34620
35285
  },
34621
35286
  };
34622
35287
 
35288
+ /**
35289
+ * A container has put its page on screen — or as much of it as it can.
35290
+ *
35291
+ * A route matching is a signal changing, and the page it selects reaches the
35292
+ * DOM only once Preact has rendered — an unknown number of passes later, in an
35293
+ * unknown number of microtasks. Anyone who needs the page as it IS rather than
35294
+ * as it has been decided (a travel about to have its picture taken by the
35295
+ * browser, see route_travel.jsx) waits for this instead of counting.
35296
+ *
35297
+ * A page waiting on data is announced too, by the boundary showing its loading
35298
+ * state (see Loading in use_async_data.jsx): what the container could put on
35299
+ * screen is what the browser is about to take a picture of, and a page that
35300
+ * cannot render yet would otherwise be waited on until the transition dies of
35301
+ * it. It lives in a module of its own for that: the async layer says it as much
35302
+ * as the router does, and neither can import the other.
35303
+ */
35304
+ const [publishRouteRender, observeRouteRender] = createPubSub();
35305
+
34623
35306
  const promiseStateWeakMap = new WeakMap();
34624
35307
  const usePromiseAsyncData = (
34625
35308
  promise,
@@ -34674,7 +35357,8 @@ const useForceRender = () => {
34674
35357
 
34675
35358
  const useAsyncData = (promiseOrAction, {
34676
35359
  loading = "delegate",
34677
- error = "delegate"
35360
+ error = "delegate",
35361
+ onLoad
34678
35362
  } = {}) => {
34679
35363
  const isAction = Boolean(promiseOrAction && promiseOrAction.isAction);
34680
35364
  if (loading === true) {
@@ -34686,7 +35370,8 @@ const useAsyncData = (promiseOrAction, {
34686
35370
  if (isAction) {
34687
35371
  return useActionAsyncData(promiseOrAction, {
34688
35372
  loadingEffect: loading,
34689
- errorEffect: error
35373
+ errorEffect: error,
35374
+ onLoad
34690
35375
  });
34691
35376
  }
34692
35377
  return usePromiseAsyncData(promiseOrAction, {
@@ -34703,12 +35388,14 @@ const dismissedActionWeakSet = new WeakSet();
34703
35388
  const dismissedActionPendingPromiseWeakMap = new WeakMap();
34704
35389
  const useActionAsyncData = (action, {
34705
35390
  loadingEffect,
34706
- errorEffect
35391
+ errorEffect,
35392
+ onLoad
34707
35393
  }) => {
34708
35394
  const loadingRef = useContext(LoadingContext);
34709
35395
  if (!loadingRef) {
34710
35396
  throw new Error("Missing <Loading>");
34711
35397
  }
35398
+ useOnLoad(action, onLoad);
34712
35399
 
34713
35400
  // Use peek() instead of .value to avoid subscribing this component to the signal.
34714
35401
  // Reading .value would make Preact re-render the component reactively when the state
@@ -34829,6 +35516,41 @@ const useActionAsyncData = (action, {
34829
35516
  throw pendingPromise;
34830
35517
  };
34831
35518
 
35519
+ // What a screen does with the data once, when it becomes known (see onLoad in
35520
+ // the JSDoc above). Kept apart because the two questions it answers are not the
35521
+ // ones the hook around it answers: WHEN — a layout effect, so a form taking its
35522
+ // reference in the same tick sees what was written; and HOW OFTEN — once per set
35523
+ // of params, which is the action's own answer to "is this another thing or the
35524
+ // same one again".
35525
+ const NOTHING_SEEDED = Symbol("nothing_seeded");
35526
+ const useOnLoad = (action, onLoad) => {
35527
+ const onLoadRef = useRef(onLoad);
35528
+ onLoadRef.current = onLoad;
35529
+ const paramsSeededRef = useRef(NOTHING_SEEDED);
35530
+ useLayoutEffect(() => {
35531
+ const callback = onLoadRef.current;
35532
+ if (!callback) {
35533
+ return;
35534
+ }
35535
+ if (action.runningStateSignal.peek() !== COMPLETED) {
35536
+ return;
35537
+ }
35538
+ const data = action.dataSignal.peek();
35539
+ if (data === undefined) {
35540
+ return;
35541
+ }
35542
+ const params = action.paramsSignal.peek();
35543
+ const paramsSeeded = paramsSeededRef.current;
35544
+ if (paramsSeeded !== NOTHING_SEEDED && compareTwoJsValues(params, paramsSeeded)) {
35545
+ return;
35546
+ }
35547
+ paramsSeededRef.current = params;
35548
+ callback(data, {
35549
+ params
35550
+ });
35551
+ });
35552
+ };
35553
+
34832
35554
  // ─── Loading ──────────────────────────────────────────────────────────────────
34833
35555
  // Wraps Suspense. Provides LoadingContext so useAction can write the suspension
34834
35556
  // reason. LoadingFallback reads that reason and subscribes to the action so it
@@ -34868,6 +35590,14 @@ const LoadingFallback = ({
34868
35590
  setTick(n => n + 1);
34869
35591
  });
34870
35592
  }, [action]);
35593
+ // A page that suspends never gets to say it is on screen — its own effects
35594
+ // are held with it — so this says it for it: what the document shows of the
35595
+ // page arriving is this. Anyone waiting for the page to be there before
35596
+ // moving (a travel about to have its picture taken, see route_travel.jsx)
35597
+ // would otherwise wait for a render that cannot happen until the data does.
35598
+ useLayoutEffect(() => {
35599
+ publishRouteRender();
35600
+ });
34871
35601
  if (loadingRef.current.reason !== "loading") {
34872
35602
  return null;
34873
35603
  }
@@ -36041,700 +36771,142 @@ const UITransition = ({
36041
36771
  alignY,
36042
36772
  ...props
36043
36773
  }) => {
36044
- const contentIdRef = useRef(contentId);
36045
- const updateContentId = () => {
36046
- const uiTransition = uiTransitionRef.current;
36047
- if (!uiTransition) {
36048
- return;
36049
- }
36050
- const value = contentIdRef.current;
36051
- uiTransition.updateContentId(value);
36052
- };
36053
- const uiTransitionContentIdContextValue = useMemo(() => {
36054
- const set = new Set();
36055
- const onSetChange = () => {
36056
- const value = Array.from(set).join("|");
36057
- contentIdRef.current = value;
36058
- updateContentId();
36059
- };
36060
- const update = (part, newPart) => {
36061
- if (!set.has(part)) {
36062
- if (set.size === 0) {
36063
- console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id set is empty`);
36064
- return;
36065
- }
36066
- console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id not found in set, only got [${Array.from(set).join(", ")}]`);
36067
- return;
36068
- }
36069
- set.delete(part);
36070
- set.add(newPart);
36071
- onSetChange();
36072
- };
36073
- const add = part => {
36074
- if (!part) {
36075
- return;
36076
- }
36077
- if (set.has(part)) {
36078
- return;
36079
- }
36080
- set.add(part);
36081
- onSetChange();
36082
- };
36083
- const remove = part => {
36084
- if (!part) {
36085
- return;
36086
- }
36087
- if (!set.has(part)) {
36088
- return;
36089
- }
36090
- set.delete(part);
36091
- onSetChange();
36092
- };
36093
- return {
36094
- add,
36095
- update,
36096
- remove
36097
- };
36098
- }, []);
36099
- const ref = useRef();
36100
- const uiTransitionRefDefault = useRef();
36101
- uiTransitionRef = uiTransitionRef || uiTransitionRefDefault;
36102
- useLayoutEffect(() => {
36103
- const uiTransition = createUITransitionController(ref.current, {
36104
- alignX,
36105
- alignY
36106
- });
36107
- uiTransitionRef.current = uiTransition;
36108
- return () => {
36109
- uiTransition.cleanup();
36110
- };
36111
- }, [disabled, alignX, alignY]);
36112
- return jsxs("div", {
36113
- ref: ref,
36114
- ...props,
36115
- className: "ui_transition",
36116
- "data-disabled": disabled ? "" : undefined,
36117
- "data-transition-type": type,
36118
- "data-transition-duration": duration,
36119
- "data-debug-detection": debugDetection ? "" : undefined,
36120
- "data-debug-size": debugSize ? "" : undefined,
36121
- "data-debug-content": debugContent ? "" : undefined,
36122
- children: [jsxs("div", {
36123
- className: "ui_transition_active_group",
36124
- children: [jsx("div", {
36125
- className: "ui_transition_target_slot",
36126
- "data-content-id": contentIdRef.current ? contentIdRef.current : undefined,
36127
- children: jsx(UITransitionContentIdContext.Provider, {
36128
- value: uiTransitionContentIdContextValue,
36129
- children: children
36130
- })
36131
- }), jsx("div", {
36132
- className: "ui_transition_outgoing_slot",
36133
- inert: true
36134
- })]
36135
- }), jsxs("div", {
36136
- className: "ui_transition_previous_group",
36137
- inert: true,
36138
- children: [jsx("div", {
36139
- className: "ui_transition_previous_target_slot"
36140
- }), jsx("div", {
36141
- className: "ui_transition_previous_outgoing_slot"
36142
- })]
36143
- })]
36144
- });
36145
- };
36146
-
36147
- /**
36148
- * The goal of this hook is to allow a component to set a "content key"
36149
- * Meaning all content within the component is identified by that key
36150
- *
36151
- * When the key changes, UITransition will be able to detect that and consider the content
36152
- * as changed even if the component is still the same
36153
- *
36154
- * This is used by <Route> to set the content key to the route path
36155
- * When the route becomes inactive it will call useUITransitionContentId(undefined)
36156
- * And if a sibling route becones active it will call useUITransitionContentId with its own path
36157
- *
36158
- */
36159
- const useUITransitionContentId = value => {
36160
- const contentId = useContext(UITransitionContentIdContext);
36161
- const valueRef = useRef();
36162
- if (contentId !== undefined && valueRef.current !== value) {
36163
- const previousValue = valueRef.current;
36164
- valueRef.current = value;
36165
- if (previousValue === undefined) {
36166
- contentId.add(value);
36167
- } else {
36168
- contentId.update(previousValue, value);
36169
- }
36170
- }
36171
- useLayoutEffect(() => {
36172
- if (contentId === undefined) {
36173
- return null;
36174
- }
36175
- return () => {
36176
- contentId.remove(valueRef.current);
36177
- };
36178
- }, []);
36179
- };
36180
-
36181
- const documentStateSignal = signal(null);
36182
- const useDocumentState = () => {
36183
- return documentStateSignal.value;
36184
- };
36185
- const updateDocumentState = (value) => {
36186
- documentStateSignal.value = value;
36187
- };
36188
-
36189
- /**
36190
- * A navigation is ABOUT to be applied — said before its very first write.
36191
- *
36192
- * Everything else a router says arrives once the change is made: a route
36193
- * announces that it matches, an action that it is running. That is too late for
36194
- * anyone who needs the page as it stands BEFORE, and the browser's view
36195
- * transitions are exactly that kind of reader — the picture they keep of the
36196
- * page being left is taken at the next frame, and a render answering a signal
36197
- * written a moment ago is already in the DOM by then (see route_travel.jsx).
36198
- *
36199
- * So this is the one moment where nothing has moved yet. It is published
36200
- * synchronously, from the top of the navigation, and whoever listens runs
36201
- * before the URL, the visited set, or any route has changed.
36202
- *
36203
- * The other end is published too, and for the same kind of reader: whoever
36204
- * held something across the change and has nobody to hand it to gets a moment
36205
- * to let go of it that does not depend on guessing how long the change takes.
36206
- */
36207
-
36208
-
36209
- const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
36210
- const [publishAfterRouting, observeAfterRouting] = createPubSub();
36211
-
36212
- const setupBrowserIntegrationViaHistory = ({
36213
- applyActions,
36214
- applyRouting,
36215
- isRouting,
36216
- }) => {
36217
- const { history } = window;
36218
-
36219
- let globalAbortController = new AbortController();
36220
- const triggerGlobalAbort = (reason) => {
36221
- globalAbortController.abort(reason);
36222
- globalAbortController = new AbortController();
36223
- };
36224
-
36225
- const dispatchActions = (params) => {
36226
- const { requestedResult } = applyActions({
36227
- globalAbortSignal: globalAbortController.signal,
36228
- abortSignal: new AbortController().signal,
36229
- ...params,
36230
- });
36231
- return requestedResult;
36232
- };
36233
- setActionDispatcher(dispatchActions);
36234
-
36235
- const getDocumentState = () => {
36236
- return window.history.state ? { ...window.history.state } : null;
36237
- };
36238
-
36239
- const historyStartAtStart = getDocumentState();
36240
- const visitedUrlSet = historyStartAtStart
36241
- ? new Set(historyStartAtStart.jsenv_visited_urls || [])
36242
- : new Set();
36243
-
36244
- // Create a signal that tracks visited URLs for reactive updates
36245
- // Using a counter instead of the Set directly for better performance
36246
- // Links will check isVisited() when this signal changes
36247
- const visitedUrlsSignal = signal(0);
36248
-
36249
- const isVisited = (url) => {
36250
- url = new URL(url, window.location.href).href;
36251
- return visitedUrlSet.has(url);
36252
- };
36253
- const markUrlAsVisited = (url) => {
36254
- if (visitedUrlSet.has(url)) {
36255
- return;
36256
- }
36257
- visitedUrlSet.add(url);
36258
- visitedUrlsSignal.value++;
36259
- };
36260
-
36261
- let abortController = null;
36262
- const handleRoutingTask = (url, options) => {
36263
- // Before anything is written: the visited set, the URL and every route are
36264
- // about to change, and this is the last moment the page still stands as it
36265
- // was. And after, whichever way the change went out — so that whoever took
36266
- // something at the first announcement has a definite place to give it back.
36267
- publishBeforeRouting({ url, ...options });
36268
- try {
36269
- return applyRoutingTask(url, options);
36270
- } finally {
36271
- publishAfterRouting({ url, ...options });
36272
- }
36273
- };
36274
-
36275
- const applyRoutingTask = (url, options) => {
36276
- const isSameUrl = url === window.location.href;
36277
- const {
36278
- reason,
36279
- navigationType, // "load", "reload", "replace", "push", "traverse"
36280
- state,
36281
- } = options;
36282
-
36283
- if (navigationType === "push" || navigationType === "replace") {
36284
- markUrlAsVisited(url);
36285
- // undefined → inherit current state (link click, neutral navigation)
36286
- // null → explicit reset (no nav-state keys carried over)
36287
- // {...} → explicit state from enter()/leave(), already built from currentState
36288
- // When state is given it's responsability of the caller to ensure it inherits document state (or not, you want it 99% of the time)
36289
- let effectiveState;
36290
- const sharedState = {
36291
- jsenv_visited_urls: Array.from(visitedUrlSet),
36292
- };
36293
- if (state === undefined) {
36294
- effectiveState = {
36295
- ...(getDocumentState() || {}),
36296
- ...sharedState,
36297
- };
36298
- } else if (state === null) {
36299
- effectiveState = sharedState;
36300
- } else if (state) {
36301
- effectiveState = {
36302
- ...state,
36303
- ...sharedState,
36304
- };
36305
- }
36306
- if (navigationType === "push") {
36307
- window.history.pushState(effectiveState, null, url);
36308
- } else {
36309
- window.history.replaceState(effectiveState, null, url);
36310
- }
36311
- updateDocumentUrl(url);
36312
- updateDocumentState(effectiveState);
36313
- } else {
36314
- // traverse / reload: state comes from the history entry, no push/replace needed.
36315
- markUrlAsVisited(url);
36316
- updateDocumentUrl(url);
36317
- updateDocumentState(state);
36318
- }
36319
-
36320
- // Skip route matching for state-only changes: push/replace to the same URL
36321
- // (e.g. useNavState updating document state without changing the route).
36322
- // Do NOT apply for "traverse" — window.location.href is already updated by
36323
- // the browser before the popstate handler runs, so isSameUrl is always true
36324
- // for back/forward navigation regardless of whether the URL actually changed.
36325
- if (
36326
- isSameUrl &&
36327
- (navigationType === "push" || navigationType === "replace")
36328
- ) {
36329
- return undefined;
36330
- }
36331
-
36332
- if (abortController) {
36333
- abortController.abort(`navigating to ${url}`);
36334
- }
36335
- abortController = new AbortController();
36336
- const abortSignal = abortController.signal;
36337
- const { allResult, requestedResult } = applyRouting(url, {
36338
- globalAbortSignal: globalAbortController.signal,
36339
- abortSignal,
36340
- reason,
36341
- navigationType,
36342
- isVisited,
36343
- state,
36344
- });
36345
- executeWithCleanup(
36346
- () => allResult,
36347
- () => {
36348
- abortController = undefined;
36349
- },
36350
- );
36351
- return requestedResult;
36352
- };
36353
-
36354
- // Browser event handlers
36355
- window.addEventListener(
36356
- "click",
36357
- (e) => {
36358
- if (e.button !== 0) {
36359
- // Ignore non-left clicks
36360
- return;
36361
- }
36362
- if (e.metaKey) {
36363
- // Ignore clicks with meta key (e.g. open in new tab)
36364
- return;
36365
- }
36366
- if (e.defaultPrevented) {
36367
- return;
36368
- }
36369
- const linkElement = e.target.closest("a");
36370
- if (!linkElement) {
36774
+ const contentIdRef = useRef(contentId);
36775
+ const updateContentId = () => {
36776
+ const uiTransition = uiTransitionRef.current;
36777
+ if (!uiTransition) {
36778
+ return;
36779
+ }
36780
+ const value = contentIdRef.current;
36781
+ uiTransition.updateContentId(value);
36782
+ };
36783
+ const uiTransitionContentIdContextValue = useMemo(() => {
36784
+ const set = new Set();
36785
+ const onSetChange = () => {
36786
+ const value = Array.from(set).join("|");
36787
+ contentIdRef.current = value;
36788
+ updateContentId();
36789
+ };
36790
+ const update = (part, newPart) => {
36791
+ if (!set.has(part)) {
36792
+ if (set.size === 0) {
36793
+ console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id set is empty`);
36794
+ return;
36795
+ }
36796
+ console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id not found in set, only got [${Array.from(set).join(", ")}]`);
36371
36797
  return;
36372
36798
  }
36373
- if (linkElement.hasAttribute("data-readonly")) {
36799
+ set.delete(part);
36800
+ set.add(newPart);
36801
+ onSetChange();
36802
+ };
36803
+ const add = part => {
36804
+ if (!part) {
36374
36805
  return;
36375
36806
  }
36376
- const href = linkElement.href;
36377
- const { isEmpty, isCurrent, isSameOrigin, isAnchor } =
36378
- getHrefTargetInfo(href);
36379
- if (isEmpty || !isSameOrigin) {
36380
- // Let link to other origins be handled by the browser
36807
+ if (set.has(part)) {
36381
36808
  return;
36382
36809
  }
36383
- if (isAnchor) {
36384
- // Fragment navigation belongs to the browser: it owns the indicated
36385
- // part of the document, and taking it over would cost `:target` and the
36386
- // focus handling that come with it.
36387
- if (isCurrent) {
36388
- // Except this one, which the browser answers with a scroll and
36389
- // nothing else: same pathname, same hash, so no event and no url
36390
- // change reaches whoever is waiting on the designated element.
36391
- rearmUrlTarget();
36392
- }
36810
+ set.add(part);
36811
+ onSetChange();
36812
+ };
36813
+ const remove = part => {
36814
+ if (!part) {
36393
36815
  return;
36394
36816
  }
36395
- // Nothing here declared a route, so there is nothing to route to: the
36396
- // page is a plain document and a link in it is a plain link. Taking it
36397
- // over anyway would push the url and then have nothing to show for it —
36398
- // the address bar moves and the page does not (see applyRouting's own
36399
- // "not called yet" branch, which is where that used to end up).
36400
- if (!isRouting()) {
36817
+ if (!set.has(part)) {
36401
36818
  return;
36402
36819
  }
36403
- e.preventDefault();
36404
- handleRoutingTask(href, {
36405
- reason: `"click" on a[href="${href}"]`,
36406
- navigationType: "push",
36407
- });
36408
- },
36409
- { capture: true },
36410
- );
36411
-
36412
- window.addEventListener(
36413
- "submit",
36414
- () => {
36415
- // Handle form submissions?
36416
- // Not needed yet
36417
- },
36418
- { capture: true },
36419
- );
36420
-
36421
- window.addEventListener("popstate", (popstateEvent) => {
36422
- const url = window.location.href;
36423
- const state = popstateEvent.state;
36424
- handleRoutingTask(url, {
36425
- reason: `"popstate" event for ${url}`,
36426
- navigationType: "traverse",
36427
- state,
36428
- });
36429
- });
36430
-
36431
- // A fragment navigation is left to the browser (see the click handler above):
36432
- // it owns the indicated part of the document, and taking it over would cost
36433
- // `:target` and the focus handling that come with it. The document url still
36434
- // has to follow it — nothing else here would notice that it moved.
36435
- window.addEventListener("hashchange", () => {
36436
- updateDocumentUrl(window.location.href);
36437
- });
36438
-
36439
- const navTo = async (url, { replace, state } = {}) => {
36440
- handleRoutingTask(url, {
36441
- reason: `navTo called with "${url}"`,
36442
- navigationType: replace ? "replace" : "push",
36443
- state,
36444
- });
36445
- };
36446
-
36447
- const stop = (reason = "stop called") => {
36448
- triggerGlobalAbort(reason);
36449
- };
36450
-
36451
- const reload = () => {
36452
- const url = window.location.href;
36453
- const state = history.state;
36454
- handleRoutingTask(url, {
36455
- reason: "reload called",
36456
- navigationType: "reload",
36457
- state,
36458
- });
36459
- };
36460
-
36461
- const navBack = () => {
36462
- window.history.back();
36463
- };
36464
-
36465
- const navForward = () => {
36466
- window.history.forward();
36467
- };
36468
-
36469
- const init = () => {
36470
- const url = window.location.href;
36471
- const state = history.state;
36472
- handleRoutingTask(url, {
36473
- reason: "routing initialization",
36474
- navigationType: "load",
36475
- state,
36476
- });
36477
- };
36478
-
36479
- return {
36480
- integration: "browser_history_api",
36481
- init,
36482
- navTo,
36483
- stop,
36484
- reload,
36485
- navBack,
36486
- navForward,
36487
- getDocumentState,
36488
- isVisited,
36489
- visitedUrlsSignal,
36490
- };
36491
- };
36492
-
36493
- let updateRoutes;
36494
-
36495
- const applyActions = (params) => {
36496
- const updateActionsResult = updateActions(params);
36497
- const { allResult, runningActionSet } = updateActionsResult;
36498
- const pendingTaskNameArray = [];
36499
- for (const runningAction of runningActionSet) {
36500
- pendingTaskNameArray.push(runningAction.name);
36501
- }
36502
- workingWhile(() => allResult, pendingTaskNameArray);
36503
- return updateActionsResult;
36504
- };
36505
- const applyRouting = (
36506
- url,
36507
- {
36508
- globalAbortSignal,
36509
- abortSignal,
36510
- // state
36511
- navigationType,
36512
- isVisited,
36513
- reason,
36514
- },
36515
- ) => {
36516
- if (!updateRoutes) {
36517
- // .init() not called yet
36518
- // likely because code does not uses routing at all
36519
- return {};
36520
- }
36521
- const {
36522
- loadSet,
36523
- reloadSet,
36524
- abortSignalMap,
36525
- routeLoadRequestedMap,
36526
- activeRouteSet,
36527
- } = updateRoutes(url, {
36528
- navigationType,
36529
- isVisited,
36530
- // state,
36531
- });
36532
- if (
36533
- (!loadSet || loadSet.size === 0) &&
36534
- (!reloadSet || reloadSet.size === 0)
36535
- ) {
36820
+ set.delete(part);
36821
+ onSetChange();
36822
+ };
36536
36823
  return {
36537
- allResult: undefined,
36538
- requestedResult: undefined,
36539
- activeRouteSet: new Set(),
36824
+ add,
36825
+ update,
36826
+ remove
36540
36827
  };
36541
- }
36542
- const updateActionsResult = updateActions({
36543
- globalAbortSignal,
36544
- abortSignal,
36545
- runSet: loadSet,
36546
- rerunSet: reloadSet,
36547
- abortSignalMap,
36548
- reason,
36549
- isReplace: navigationType === "replace",
36550
- });
36551
- const { allResult, runningActionSet } = updateActionsResult;
36552
- const pendingTaskNameArray = [];
36553
- for (const [route, routeAction] of routeLoadRequestedMap) {
36554
- if (runningActionSet.has(routeAction)) {
36555
- pendingTaskNameArray.push(`${route.relativeUrl} -> ${routeAction.name}`);
36556
- }
36557
- }
36558
- routingWhile(() => allResult, pendingTaskNameArray);
36559
- return { ...updateActionsResult, activeRouteSet };
36560
- };
36561
-
36562
- const browserIntegration = setupBrowserIntegrationViaHistory({
36563
- applyActions,
36564
- applyRouting,
36565
- // Routes are declared by the consumer and registered through
36566
- // setOnAllRouteReady below, so "does this document route at all?" is only
36567
- // answerable once that has run — hence a function, read at click time rather
36568
- // than a value read at setup time.
36569
- isRouting: () => Boolean(updateRoutes),
36570
- });
36571
-
36572
- setOnAllRouteReady((v) => {
36573
- updateRoutes = v;
36574
- browserIntegration.init();
36575
- });
36576
- setRouteIntegration(browserIntegration);
36577
-
36578
- const navIntegratedVia = browserIntegration.integration;
36579
- const navTo = (target, options) => {
36580
- const url = new URL(target, window.location.href).href;
36581
- const currentUrl = documentUrlSignal.peek();
36582
- if (url === currentUrl) {
36583
- if (options?.state === undefined) {
36584
- return null;
36585
- }
36586
- // State-only update on same URL: skip if state is identical to current.
36587
- const currentState = browserIntegration.getDocumentState();
36588
- if (compareTwoJsValues(options.state, currentState)) {
36589
- return null;
36590
- }
36591
- }
36592
- return browserIntegration.navTo(url, options);
36593
- };
36594
- const stopLoad = (reason = "stopLoad() called") => {
36595
- const windowIsLoading = windowIsLoadingSignal.value;
36596
- if (windowIsLoading) {
36597
- window.stop();
36598
- }
36599
- const documentIsBusy = documentIsBusySignal.value;
36600
- if (documentIsBusy) {
36601
- browserIntegration.stop(reason);
36602
- }
36603
- };
36604
- const reload = browserIntegration.reload;
36605
- const navBack = browserIntegration.navBack;
36606
- const navForward = browserIntegration.navForward;
36607
- const isVisited = browserIntegration.isVisited;
36608
- const visitedUrlsSignal = browserIntegration.visitedUrlsSignal;
36609
- browserIntegration.handleActionTask;
36610
-
36611
- // Preact's own useId() (see preact/hooks) returns "P<mask0>-<mask1>", where
36612
- // the mask is derived from render order within the nearest root/async
36613
- // boundary — stable across re-renders of the *same* mount, but not across a
36614
- // reload (render order can differ) or even across two mounts on the same
36615
- // page (two components hitting useId() in the same relative order get the
36616
- // same string). Storing one of these under type: "push" bakes it into a
36617
- // history entry: reload the page and the entry's key may now belong to a
36618
- // completely different component (or none), silently auto-opening whatever
36619
- // happens to render at that same position instead.
36620
- const PREACT_GENERATED_ID_REGEX = /^P\d+-\d+/;
36621
- const isLikelyPreactGeneratedId = (id) => PREACT_GENERATED_ID_REGEX.test(id);
36622
-
36623
- const NO_OP = () => {};
36624
- const NO_ID_GIVEN = [undefined, NO_OP, NO_OP];
36625
- const useNavStateBasic = (
36626
- id,
36627
- { debug, type = "replace", onLeave, defaultValue } = {},
36628
- ) => {
36629
- // Hooks must be called unconditionally — before the !id early return.
36630
- const state = documentStateSignal.value;
36631
- // Key presence is the flag — the value may be anything, including undefined.
36632
- const keyInState = Boolean(id && state && Object.hasOwn(state, id));
36633
- const onLeaveRef = useRef(onLeave);
36634
- onLeaveRef.current = onLeave;
36635
- const prevKeyInStateRef = useRef(keyInState);
36636
- // enteredRef tracks whether enter() was called without a matching leave() yet.
36637
- // It lets the effect distinguish an external disappearance (back button → fire onLeave)
36638
- // from a programmatic one (leave() already set it to false before the state updates).
36639
- const enteredRef = useRef(false);
36640
- useEffect(() => {
36641
- const prevKeyInState = prevKeyInStateRef.current;
36642
- prevKeyInStateRef.current = keyInState;
36643
- if (prevKeyInState && !keyInState && enteredRef.current) {
36644
- enteredRef.current = false;
36645
- onLeaveRef.current?.();
36646
- }
36647
- }, [keyInState]);
36648
-
36649
- if (!id) {
36650
- return NO_ID_GIVEN;
36651
- }
36652
-
36653
- let effectiveType = type;
36654
- if (type === "push" && isLikelyPreactGeneratedId(id)) {
36655
- effectiveType = "replace";
36656
- }
36657
-
36658
- const currentValue = keyInState ? state[id] : defaultValue;
36659
-
36660
- if (debug) {
36661
- console.debug(`useNavState(${id}) current value is ${currentValue}`);
36662
- }
36663
-
36664
- // enter(value): navigate TO this state (push or replace depending on type).
36665
- // Calling enter() without a value stores "on" — the mere presence of the key
36666
- // in the document state is enough to match; the value just allows associating
36667
- // extra data with the entry when needed.
36668
- const enter = (value = "on") => {
36669
- enteredRef.current = true;
36670
- const currentStateCopy = browserIntegration.getDocumentState() || {};
36671
- if (Object.hasOwn(currentStateCopy, id) && currentStateCopy[id] === value) {
36672
- return;
36673
- }
36674
- currentStateCopy[id] = value;
36675
- navTo(window.location.href, {
36676
- replace: effectiveType !== "push",
36677
- state: currentStateCopy,
36828
+ }, []);
36829
+ const ref = useRef();
36830
+ const uiTransitionRefDefault = useRef();
36831
+ uiTransitionRef = uiTransitionRef || uiTransitionRefDefault;
36832
+ useLayoutEffect(() => {
36833
+ const uiTransition = createUITransitionController(ref.current, {
36834
+ alignX,
36835
+ alignY
36678
36836
  });
36679
- };
36680
-
36681
- // leave(): navigate AWAY FROM this state (navBack in push mode, replace in replace mode).
36682
- // isBack: when true (cancel close in push mode), call history.back() to restore the
36683
- // pre-open state discards any in-progress edits.
36684
- // When false (confirmed close), replace the pushed entry instead: preserves the
36685
- // current URL state (e.g. a new picker value) while removing the popup key.
36686
- const leave = ({ isBack } = {}) => {
36687
- enteredRef.current = false;
36688
- const currentStateCopy = browserIntegration.getDocumentState() || {};
36689
- if (!Object.hasOwn(currentStateCopy, id)) {
36690
- return;
36691
- }
36692
- if (effectiveType === "push" && isBack) {
36693
- browserIntegration.navBack();
36694
- } else {
36695
- delete currentStateCopy[id];
36696
- navTo(window.location.href, {
36697
- replace: true,
36698
- state: currentStateCopy,
36699
- });
36700
- }
36701
- };
36702
-
36703
- return [currentValue, enter, leave];
36837
+ uiTransitionRef.current = uiTransition;
36838
+ return () => {
36839
+ uiTransition.cleanup();
36840
+ };
36841
+ }, [disabled, alignX, alignY]);
36842
+ return jsxs("div", {
36843
+ ref: ref,
36844
+ ...props,
36845
+ className: "ui_transition",
36846
+ "data-disabled": disabled ? "" : undefined,
36847
+ "data-transition-type": type,
36848
+ "data-transition-duration": duration,
36849
+ "data-debug-detection": debugDetection ? "" : undefined,
36850
+ "data-debug-size": debugSize ? "" : undefined,
36851
+ "data-debug-content": debugContent ? "" : undefined,
36852
+ children: [jsxs("div", {
36853
+ className: "ui_transition_active_group",
36854
+ children: [jsx("div", {
36855
+ className: "ui_transition_target_slot",
36856
+ "data-content-id": contentIdRef.current ? contentIdRef.current : undefined,
36857
+ children: jsx(UITransitionContentIdContext.Provider, {
36858
+ value: uiTransitionContentIdContextValue,
36859
+ children: children
36860
+ })
36861
+ }), jsx("div", {
36862
+ className: "ui_transition_outgoing_slot",
36863
+ inert: true
36864
+ })]
36865
+ }), jsxs("div", {
36866
+ className: "ui_transition_previous_group",
36867
+ inert: true,
36868
+ children: [jsx("div", {
36869
+ className: "ui_transition_previous_target_slot"
36870
+ }), jsx("div", {
36871
+ className: "ui_transition_previous_outgoing_slot"
36872
+ })]
36873
+ })]
36874
+ });
36704
36875
  };
36705
36876
 
36706
36877
  /**
36707
- * Stores a named value in the browser's document state and returns it reactively.
36708
- * The component re-renders whenever the value changes (navigation, back/forward button).
36878
+ * The goal of this hook is to allow a component to set a "content key"
36879
+ * Meaning all content within the component is identified by that key
36709
36880
  *
36710
- * @param {string} id
36711
- * Unique key used to store the value in document state. Must be stable across renders.
36881
+ * When the key changes, UITransition will be able to detect that and consider the content
36882
+ * as changed even if the component is still the same
36712
36883
  *
36713
- * @param {object} [options]
36714
- * @param {"push"|"replace"} [options.type="replace"]
36715
- * Controls how enter() adds the state to browser history.
36716
- * - "push": creates a new history entry — pressing the back button removes it and calls onLeave.
36717
- * - "replace": updates the current history entry — no extra history entry is created.
36718
- * Silently downgraded to "replace" (with a dev-only console.warn) when `id`
36719
- * looks auto-generated (e.g. preact's own useId()) — an unstable id baked
36720
- * into a pushed history entry won't survive a reload correctly, and could
36721
- * even collide with a different component's own auto-generated id. Pass a
36722
- * stable, explicit id to actually get "push" behavior.
36723
- * @param {() => void} [options.onLeave]
36724
- * Called when the state key disappears **externally** — e.g. the user presses the browser
36725
- * back button. Not called when leave() is invoked programmatically.
36726
- * @param {*} [options.defaultValue]
36727
- * Value returned when `id` is absent from document state. Defaults to `undefined`.
36884
+ * This is used by <Route> to set the content key to the route path
36885
+ * When the route becomes inactive it will call useUITransitionContentId(undefined)
36886
+ * And if a sibling route becones active it will call useUITransitionContentId with its own path
36728
36887
  *
36729
- * @returns {[value, enter, leave]}
36730
- * - `value`: current value from document state, or `defaultValue` when the key is absent.
36731
- * - `enter(value = "on")`: navigate TO this state (stores `value` under `id`).
36732
- * Calling without an argument stores `"on"` — the presence of the key is enough to match;
36733
- * the value allows associating extra data when needed.
36734
- * - `leave()`: navigate AWAY FROM this state (removes `id` from document state,
36735
- * or goes back in history when `type` is "push").
36736
36888
  */
36737
- const useNavState = useNavStateBasic;
36889
+ const useUITransitionContentId = value => {
36890
+ const contentId = useContext(UITransitionContentIdContext);
36891
+ const valueRef = useRef();
36892
+ if (contentId !== undefined && valueRef.current !== value) {
36893
+ const previousValue = valueRef.current;
36894
+ valueRef.current = value;
36895
+ if (previousValue === undefined) {
36896
+ contentId.add(value);
36897
+ } else {
36898
+ contentId.update(previousValue, value);
36899
+ }
36900
+ }
36901
+ useLayoutEffect(() => {
36902
+ if (contentId === undefined) {
36903
+ return null;
36904
+ }
36905
+ return () => {
36906
+ contentId.remove(valueRef.current);
36907
+ };
36908
+ }, []);
36909
+ };
36738
36910
 
36739
36911
  const NEVER_SET = {};
36740
36912
  const useUrlSearchParam = (paramName, defaultValue) => {
@@ -36858,7 +37030,6 @@ const Head = ({
36858
37030
  * ```
36859
37031
  */
36860
37032
 
36861
- const [publishRouteRender, observeRouteRender] = createPubSub();
36862
37033
 
36863
37034
  /**
36864
37035
  * Keep every container showing the page it is showing, whatever the routes say.
@@ -37146,6 +37317,10 @@ installImportMetaCssBuild(import.meta);/**
37146
37317
  const CAN_KEEP_PICTURE = Boolean(document.startViewTransition && !document.startViewTransition.isPolyfill);
37147
37318
  const startViewTransition = ensureDocumentStartViewTransition();
37148
37319
  const TRAVEL_ATTRIBUTE = "data-navi-route-travel";
37320
+ // Which way the pages move, said on the document: the pictures of a transition
37321
+ // hang off the root, not off the box that travels, so the box's own `axis` has
37322
+ // to be lent to the document for the length of the travel.
37323
+ const TRAVEL_AXIS_ATTRIBUTE = "data-navi-route-travel-axis";
37149
37324
  // While a finger holds the travel: the pictures stand still and go exactly
37150
37325
  // where it says (see the CSS, and scrubTravel).
37151
37326
  const HOLD_ATTRIBUTE = "data-navi-route-travel-held";
@@ -37339,6 +37514,28 @@ const css$R = /* css */`
37339
37514
  }
37340
37515
  }
37341
37516
 
37517
+ /* The same four movements, along the axis the pages are laid out on: the
37518
+ start of a column is its top, so going forward there is the page rising and
37519
+ the next one coming up from below. */
37520
+ :root[${TRAVEL_AXIS_ATTRIBUTE}="y"] {
37521
+ &[${TRAVEL_ATTRIBUTE}="forward"] {
37522
+ &::view-transition-old(navi-route-travel) {
37523
+ animation-name: navi-route-travel-leave-towards-top;
37524
+ }
37525
+ &::view-transition-new(navi-route-travel) {
37526
+ animation-name: navi-route-travel-enter-from-bottom;
37527
+ }
37528
+ }
37529
+ &[${TRAVEL_ATTRIBUTE}="back"] {
37530
+ &::view-transition-old(navi-route-travel) {
37531
+ animation-name: navi-route-travel-leave-towards-bottom;
37532
+ }
37533
+ &::view-transition-new(navi-route-travel) {
37534
+ animation-name: navi-route-travel-enter-from-top;
37535
+ }
37536
+ }
37537
+ }
37538
+
37342
37539
  @keyframes navi-route-travel-leave-towards-start {
37343
37540
  from {
37344
37541
  translate: 0 0;
@@ -37371,6 +37568,38 @@ const css$R = /* css */`
37371
37568
  translate: 0 0;
37372
37569
  }
37373
37570
  }
37571
+ @keyframes navi-route-travel-leave-towards-top {
37572
+ from {
37573
+ translate: 0 0;
37574
+ }
37575
+ to {
37576
+ translate: 0 -100%;
37577
+ }
37578
+ }
37579
+ @keyframes navi-route-travel-enter-from-bottom {
37580
+ from {
37581
+ translate: 0 100%;
37582
+ }
37583
+ to {
37584
+ translate: 0 0;
37585
+ }
37586
+ }
37587
+ @keyframes navi-route-travel-leave-towards-bottom {
37588
+ from {
37589
+ translate: 0 0;
37590
+ }
37591
+ to {
37592
+ translate: 0 100%;
37593
+ }
37594
+ }
37595
+ @keyframes navi-route-travel-enter-from-top {
37596
+ from {
37597
+ translate: 0 -100%;
37598
+ }
37599
+ to {
37600
+ translate: 0 0;
37601
+ }
37602
+ }
37374
37603
  `;
37375
37604
 
37376
37605
  /**
@@ -37507,6 +37736,7 @@ const RouteTravel = ({
37507
37736
  // own for as long as it is the one travelling.
37508
37737
  nameForTravel(elementRef.current);
37509
37738
  document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
37739
+ document.documentElement.setAttribute(TRAVEL_AXIS_ATTRIBUTE, axis);
37510
37740
  if (scrub) {
37511
37741
  holdPictures(travel);
37512
37742
  document.documentElement.setAttribute(DRAGGED_ATTRIBUTE, "");
@@ -37520,14 +37750,21 @@ const RouteTravel = ({
37520
37750
  const releaseRendering = renderingHeldForRouting || holdRendering();
37521
37751
  renderingHeldForRouting = null;
37522
37752
  // The picture the browser is about to take must be of the page that was
37523
- // asked for, and a route matching is not yet a page rendered.
37753
+ // asked for, and a route matching is not yet a page rendered. Watched from
37754
+ // here rather than from inside the callback below: the browser calls that
37755
+ // callback a frame later, and a navigation that has already been decided
37756
+ // (what follows a send, a command) renders its page in between. A wait
37757
+ // armed then waits for something that has already happened — until the
37758
+ // browser gives up on the transition, leaving the page it was leaving on
37759
+ // screen and an error nobody asked for.
37760
+ const renderWait = armRouteRenderWait();
37524
37761
  const viewTransition = startViewTransition(async () => {
37525
37762
  await whilePageRenders(page, async () => {
37526
37763
  releaseRendering();
37527
37764
  if (change) {
37528
37765
  await change();
37529
37766
  }
37530
- });
37767
+ }, renderWait);
37531
37768
  // The page arriving is in the DOM and the transition has not started
37532
37769
  // playing: the one moment both boxes can be known.
37533
37770
  holdTravelHeight(elementRef.current, heightBefore);
@@ -37552,6 +37789,7 @@ const RouteTravel = ({
37552
37789
  viewTransition.finished.catch(() => {
37553
37790
  // A transition that fails before it ever calls back leaves the page held:
37554
37791
  // whoever asked for the hold gives it back, here as everywhere else.
37792
+ renderWait.stop();
37555
37793
  releaseRendering();
37556
37794
  endTravel(travel);
37557
37795
  });
@@ -37855,6 +38093,7 @@ const RouteTravel = ({
37855
38093
  // rather than pick.
37856
38094
  unnameAfterTravel(elementRef.current);
37857
38095
  document.documentElement.removeAttribute(TRAVEL_ATTRIBUTE);
38096
+ document.documentElement.removeAttribute(TRAVEL_AXIS_ATTRIBUTE);
37858
38097
  document.documentElement.removeAttribute(DRAGGED_ATTRIBUTE);
37859
38098
  document.documentElement.removeAttribute(TURNED_ATTRIBUTE);
37860
38099
  releaseTravelHeight();
@@ -38417,20 +38656,30 @@ const scrubTravel = (travel, ratio) => {
38417
38656
  // inside the callback of a view transition: the browser has stopped rendering
38418
38657
  // and is waiting on this very promise to take its picture, so a wait that never
38419
38658
  // ends is a page frozen under a transition that never became ready.
38420
- const whilePageRenders = async (page, change) => {
38659
+ // Listening starts before the change, or a render landing while the change is
38660
+ // being awaited is a render nobody heard. Armed apart from the wait itself
38661
+ // because the two do not always happen at the same moment: a view transition
38662
+ // calls its update callback a frame after it is started, and the render can
38663
+ // land in that gap — see beginTravel, which arms this the moment the travel is
38664
+ // decided and hands it over.
38665
+ const armRouteRenderWait = () => {
38421
38666
  let stopListening;
38422
38667
  const rendered = new Promise(resolve => {
38423
- // Listened for before the change, or a render landing while the change is
38424
- // being awaited is a render nobody heard.
38425
38668
  stopListening = observeRouteRender(resolve);
38426
38669
  });
38670
+ return {
38671
+ rendered,
38672
+ stop: () => stopListening()
38673
+ };
38674
+ };
38675
+ const whilePageRenders = async (page, change, wait = armRouteRenderWait()) => {
38427
38676
  try {
38428
38677
  await change();
38429
38678
  if (pageIsCurrent(page)) {
38430
- await rendered;
38679
+ await wait.rendered;
38431
38680
  }
38432
38681
  } finally {
38433
- stopListening();
38682
+ wait.stop();
38434
38683
  }
38435
38684
  };
38436
38685
 
@@ -38478,12 +38727,20 @@ const pageIsCurrent = ({
38478
38727
  }
38479
38728
  return params ? route.matchesParams(params) : true;
38480
38729
  };
38481
- // Every page is read, never only up to the one that answers yes: a page that is
38482
- // not the current one today is the one that must wake the reader tomorrow.
38730
+ // The FIRST page that answers, as with the branches of a <Route>: several
38731
+ // routes match at once — a literal one and the parameterized one it is a case of
38732
+ // ("/games/new" is also a "/games/:gameId"), a section and the page inside it —
38733
+ // and the row has to be on the page the router is showing, which is the first
38734
+ // one written that matches.
38735
+ //
38736
+ // Every page is read all the same, never only up to the one that answers yes: a
38737
+ // page that is not the current one today is the one that must wake the reader
38738
+ // tomorrow.
38483
38739
  const currentPageIndex = pages => {
38484
38740
  let currentIndex = -1;
38485
38741
  for (let i = 0; i < pages.length; i++) {
38486
- if (pageIsCurrent(pages[i])) {
38742
+ const isCurrent = pageIsCurrent(pages[i]);
38743
+ if (isCurrent && currentIndex === -1) {
38487
38744
  currentIndex = i;
38488
38745
  }
38489
38746
  }
@@ -47865,21 +48122,51 @@ const withoutEmptyFields = uiState => {
47865
48122
  // register themselves in their own effects, which run first — this is the
47866
48123
  // earliest moment the form knows what it holds. Everything after this baseline
47867
48124
  // is a real send moving it forward (see useFormGroup's own onnavi_action_end).
48125
+ //
48126
+ // Taken a second time at the end of the tick, because "the earliest moment" is
48127
+ // not always late enough: a field that re-renders on its own schedule rather
48128
+ // than with the form — a row whose value is computed from signals, sitting
48129
+ // behind a memo — brings its value in a render of its own, which lands after
48130
+ // these effects. A form measured before it would open already changed, and
48131
+ // would never take the reference again. Both takes are the same arrival, so the
48132
+ // second one costs a render only when it moves something.
47868
48133
  const useHeldUIStateAsSent = (uiStateController, pristineKey) => {
47869
48134
  // The render that brought a new pristineKey read `changed` against the
47870
48135
  // previous baseline, and nothing else is going to move: the button would stay
47871
48136
  // lit on a form that holds exactly what it was just given. So ask for the one
47872
- // render that reads the new baseline — the first one has nobody to tell,
47873
- // every field it is waiting for re-renders the form as it registers.
48137
+ // render that reads the new baseline — the first take on mount has nobody to
48138
+ // tell, every field it is waiting for re-renders the form as it registers.
47874
48139
  const [, rereadBaseline] = useState(0);
47875
48140
  const isFirstRef = useRef(true);
47876
48141
  useLayoutEffect(() => {
47877
- uiStateController.sentUIState = readHeldUIState(uiStateController);
47878
- if (isFirstRef.current) {
47879
- isFirstRef.current = false;
47880
- return;
47881
- }
47882
- rereadBaseline(count => count + 1);
48142
+ const takeBaseline = () => {
48143
+ const baselineBefore = uiStateController.sentUIState;
48144
+ const baseline = readHeldUIState(uiStateController);
48145
+ uiStateController.sentUIState = baseline;
48146
+ return !compareTwoJsValues(baselineBefore, baseline);
48147
+ };
48148
+ const moved = takeBaseline();
48149
+ const isFirst = isFirstRef.current;
48150
+ isFirstRef.current = false;
48151
+ if (moved && !isFirst) {
48152
+ rereadBaseline(count => count + 1);
48153
+ }
48154
+ // A microtask, not a timeout: everything that belongs to this arrival —
48155
+ // the renders preact still has queued, the state they push into the form —
48156
+ // happens before the tick ends, and nothing a person does can land in
48157
+ // between.
48158
+ let abandoned = false;
48159
+ queueMicrotask(() => {
48160
+ if (abandoned) {
48161
+ return;
48162
+ }
48163
+ if (takeBaseline()) {
48164
+ rereadBaseline(count => count + 1);
48165
+ }
48166
+ });
48167
+ return () => {
48168
+ abandoned = true;
48169
+ };
47883
48170
  }, [uiStateController, pristineKey]);
47884
48171
  };
47885
48172
  const useUnregisteredControlWarning = ref => {
@@ -52273,7 +52560,7 @@ const PickerContentInsidePopup = props => {
52273
52560
  // above: those exist because "expand" already means something on the picker
52274
52561
  // itself, and this one does not. Popover ignores it, same as Dialog ignores
52275
52562
  // marginWithAnchor.
52276
- dockedOnTouch,
52563
+ dockedOnSmallTouchScreen,
52277
52564
  animation,
52278
52565
  ...rest
52279
52566
  } = props;
@@ -52321,7 +52608,7 @@ const PickerContentInsidePopup = props => {
52321
52608
  expand: isPopover ? undefined : dialogExpand,
52322
52609
  expandX: isPopover ? undefined : dialogExpandX,
52323
52610
  expandY: isPopover ? undefined : dialogExpandY,
52324
- dockedOnTouch: isPopover ? undefined : dockedOnTouch,
52611
+ dockedOnSmallTouchScreen: isPopover ? undefined : dockedOnSmallTouchScreen,
52325
52612
  children: jsx(PopupModeContext.Provider, {
52326
52613
  value: mode,
52327
52614
  children: children
@@ -53999,7 +54286,7 @@ const css$v = /* css */`
53999
54286
 
54000
54287
  /* Same reasoning, for the corners: a dialog squares off whatever corner
54001
54288
  lands on its container's own (see the data-flush-* rules in dialog.jsx —
54002
- a bottom sheet from dockedOnTouch squares its two bottom ones). A list
54289
+ a bottom sheet from dockedOnSmallTouchScreen squares its two bottom ones). A list
54003
54290
  drawn right against that corner has to square the same one, otherwise its
54004
54291
  own radius carves a notch out of the popup's square corner. Direct child
54005
54292
  only: any deeper and the list is presumably inset from the popup's edge,
@@ -70991,5 +71278,5 @@ const UserSvg = () => jsx("svg", {
70991
71278
  })
70992
71279
  });
70993
71280
 
70994
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeSpin, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
71281
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeSpin, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
70995
71282
  //# sourceMappingURL=jsenv_navi.js.map