@jsenv/navi 0.29.44 → 0.29.45

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,7 @@
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, coarsePointerSignal } from "./jsenv_navi_side_effects.js";
6
6
  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
7
  export { contrastColor, findEvent, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, batch, useSignal } from "@preact/signals";
@@ -17237,15 +17237,26 @@ const isSizeSpacingKey = (key) => {
17237
17237
  // "vvw"/"vvh" are navi's own: the *visual* viewport, which — unlike vw/dvw —
17238
17238
  // shrinks when the mobile virtual keyboard opens (see layout/responsive.js), so
17239
17239
  // 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)$/;
17240
+ // "appw"/"apph" are the same thing narrowed to the app's own screen: identical
17241
+ // to vvw/vvh until the app declares --navi-app-max-width, and a share of that
17242
+ // width afterwards. A gap meant to read as "a small margin" must use these —
17243
+ // 3vvw on a 1500px window is a 45px gap around a 600px app.
17244
+ // Functions rather than the signals themselves: appw/apph are not a signal to
17245
+ // read but a value to compute (a signal, then a CSS var read back). Reading the
17246
+ // signal inside still registers the same dependency for a caller doing this
17247
+ // during a render.
17248
+ const VIEWPORT_UNIT_VALUES = {
17249
+ appw: getAppWidth,
17250
+ apph: getAppHeight,
17251
+ vvw: () => visualViewportWidthSignal.value,
17252
+ vvh: () => visualViewportHeightSignal.value,
17253
+ vw: () => windowWidthSignal.value,
17254
+ vh: () => windowHeightSignal.value,
17255
+ dvw: () => windowWidthSignal.value,
17256
+ dvh: () => windowHeightSignal.value,
17257
+ };
17258
+ const VIEWPORT_LENGTH_REGEX =
17259
+ /^(-?\d+(?:\.\d+)?)(appw|apph|vvw|vvh|dvw|dvh|vw|vh)$/;
17249
17260
  const resolveViewportLength = (size) => {
17250
17261
  if (typeof size !== "string") {
17251
17262
  return null;
@@ -17255,7 +17266,7 @@ const resolveViewportLength = (size) => {
17255
17266
  return null;
17256
17267
  }
17257
17268
  const [, amount, unit] = match;
17258
- return (parseFloat(amount) / 100) * VIEWPORT_UNIT_SIGNALS[unit].value;
17269
+ return (parseFloat(amount) / 100) * VIEWPORT_UNIT_VALUES[unit]();
17259
17270
  };
17260
17271
 
17261
17272
  // "3cqw"/"2cqh" — a share of the container the given element lives in, the way
@@ -21546,6 +21557,564 @@ document.body.addEventListener(
21546
21557
  { capture: true },
21547
21558
  );
21548
21559
 
21560
+ const documentStateSignal = signal(null);
21561
+ const useDocumentState = () => {
21562
+ return documentStateSignal.value;
21563
+ };
21564
+ const updateDocumentState = (value) => {
21565
+ documentStateSignal.value = value;
21566
+ };
21567
+
21568
+ /**
21569
+ * A navigation is ABOUT to be applied — said before its very first write.
21570
+ *
21571
+ * Everything else a router says arrives once the change is made: a route
21572
+ * announces that it matches, an action that it is running. That is too late for
21573
+ * anyone who needs the page as it stands BEFORE, and the browser's view
21574
+ * transitions are exactly that kind of reader — the picture they keep of the
21575
+ * page being left is taken at the next frame, and a render answering a signal
21576
+ * written a moment ago is already in the DOM by then (see route_travel.jsx).
21577
+ *
21578
+ * So this is the one moment where nothing has moved yet. It is published
21579
+ * synchronously, from the top of the navigation, and whoever listens runs
21580
+ * before the URL, the visited set, or any route has changed.
21581
+ *
21582
+ * The other end is published too, and for the same kind of reader: whoever
21583
+ * held something across the change and has nobody to hand it to gets a moment
21584
+ * to let go of it that does not depend on guessing how long the change takes.
21585
+ */
21586
+
21587
+
21588
+ const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
21589
+ const [publishAfterRouting, observeAfterRouting] = createPubSub();
21590
+
21591
+ const setupBrowserIntegrationViaHistory = ({
21592
+ applyActions,
21593
+ applyRouting,
21594
+ isRouting,
21595
+ }) => {
21596
+ const { history } = window;
21597
+
21598
+ let globalAbortController = new AbortController();
21599
+ const triggerGlobalAbort = (reason) => {
21600
+ globalAbortController.abort(reason);
21601
+ globalAbortController = new AbortController();
21602
+ };
21603
+
21604
+ const dispatchActions = (params) => {
21605
+ const { requestedResult } = applyActions({
21606
+ globalAbortSignal: globalAbortController.signal,
21607
+ abortSignal: new AbortController().signal,
21608
+ ...params,
21609
+ });
21610
+ return requestedResult;
21611
+ };
21612
+ setActionDispatcher(dispatchActions);
21613
+
21614
+ const getDocumentState = () => {
21615
+ return window.history.state ? { ...window.history.state } : null;
21616
+ };
21617
+
21618
+ const historyStartAtStart = getDocumentState();
21619
+ const visitedUrlSet = historyStartAtStart
21620
+ ? new Set(historyStartAtStart.jsenv_visited_urls || [])
21621
+ : new Set();
21622
+
21623
+ // Create a signal that tracks visited URLs for reactive updates
21624
+ // Using a counter instead of the Set directly for better performance
21625
+ // Links will check isVisited() when this signal changes
21626
+ const visitedUrlsSignal = signal(0);
21627
+
21628
+ const isVisited = (url) => {
21629
+ url = new URL(url, window.location.href).href;
21630
+ return visitedUrlSet.has(url);
21631
+ };
21632
+ const markUrlAsVisited = (url) => {
21633
+ if (visitedUrlSet.has(url)) {
21634
+ return;
21635
+ }
21636
+ visitedUrlSet.add(url);
21637
+ visitedUrlsSignal.value++;
21638
+ };
21639
+
21640
+ let abortController = null;
21641
+ const handleRoutingTask = (url, options) => {
21642
+ // Before anything is written: the visited set, the URL and every route are
21643
+ // about to change, and this is the last moment the page still stands as it
21644
+ // was. And after, whichever way the change went out — so that whoever took
21645
+ // something at the first announcement has a definite place to give it back.
21646
+ publishBeforeRouting({ url, ...options });
21647
+ try {
21648
+ return applyRoutingTask(url, options);
21649
+ } finally {
21650
+ publishAfterRouting({ url, ...options });
21651
+ }
21652
+ };
21653
+
21654
+ const applyRoutingTask = (url, options) => {
21655
+ const isSameUrl = url === window.location.href;
21656
+ const {
21657
+ reason,
21658
+ navigationType, // "load", "reload", "replace", "push", "traverse"
21659
+ state,
21660
+ } = options;
21661
+
21662
+ if (navigationType === "push" || navigationType === "replace") {
21663
+ markUrlAsVisited(url);
21664
+ // undefined → inherit current state (link click, neutral navigation)
21665
+ // null → explicit reset (no nav-state keys carried over)
21666
+ // {...} → explicit state from enter()/leave(), already built from currentState
21667
+ // 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)
21668
+ let effectiveState;
21669
+ const sharedState = {
21670
+ jsenv_visited_urls: Array.from(visitedUrlSet),
21671
+ };
21672
+ if (state === undefined) {
21673
+ effectiveState = {
21674
+ ...(getDocumentState() || {}),
21675
+ ...sharedState,
21676
+ };
21677
+ } else if (state === null) {
21678
+ effectiveState = sharedState;
21679
+ } else if (state) {
21680
+ effectiveState = {
21681
+ ...state,
21682
+ ...sharedState,
21683
+ };
21684
+ }
21685
+ if (navigationType === "push") {
21686
+ window.history.pushState(effectiveState, null, url);
21687
+ } else {
21688
+ window.history.replaceState(effectiveState, null, url);
21689
+ }
21690
+ updateDocumentUrl(url);
21691
+ updateDocumentState(effectiveState);
21692
+ } else {
21693
+ // traverse / reload: state comes from the history entry, no push/replace needed.
21694
+ markUrlAsVisited(url);
21695
+ updateDocumentUrl(url);
21696
+ updateDocumentState(state);
21697
+ }
21698
+
21699
+ // Skip route matching for state-only changes: push/replace to the same URL
21700
+ // (e.g. useNavState updating document state without changing the route).
21701
+ // Do NOT apply for "traverse" — window.location.href is already updated by
21702
+ // the browser before the popstate handler runs, so isSameUrl is always true
21703
+ // for back/forward navigation regardless of whether the URL actually changed.
21704
+ if (
21705
+ isSameUrl &&
21706
+ (navigationType === "push" || navigationType === "replace")
21707
+ ) {
21708
+ return undefined;
21709
+ }
21710
+
21711
+ if (abortController) {
21712
+ abortController.abort(`navigating to ${url}`);
21713
+ }
21714
+ abortController = new AbortController();
21715
+ const abortSignal = abortController.signal;
21716
+ const { allResult, requestedResult } = applyRouting(url, {
21717
+ globalAbortSignal: globalAbortController.signal,
21718
+ abortSignal,
21719
+ reason,
21720
+ navigationType,
21721
+ isVisited,
21722
+ state,
21723
+ });
21724
+ executeWithCleanup(
21725
+ () => allResult,
21726
+ () => {
21727
+ abortController = undefined;
21728
+ },
21729
+ );
21730
+ return requestedResult;
21731
+ };
21732
+
21733
+ // Browser event handlers
21734
+ window.addEventListener(
21735
+ "click",
21736
+ (e) => {
21737
+ if (e.button !== 0) {
21738
+ // Ignore non-left clicks
21739
+ return;
21740
+ }
21741
+ if (e.metaKey) {
21742
+ // Ignore clicks with meta key (e.g. open in new tab)
21743
+ return;
21744
+ }
21745
+ if (e.defaultPrevented) {
21746
+ return;
21747
+ }
21748
+ const linkElement = e.target.closest("a");
21749
+ if (!linkElement) {
21750
+ return;
21751
+ }
21752
+ if (linkElement.hasAttribute("data-readonly")) {
21753
+ return;
21754
+ }
21755
+ const href = linkElement.href;
21756
+ const { isEmpty, isCurrent, isSameOrigin, isAnchor } =
21757
+ getHrefTargetInfo(href);
21758
+ if (isEmpty || !isSameOrigin) {
21759
+ // Let link to other origins be handled by the browser
21760
+ return;
21761
+ }
21762
+ if (isAnchor) {
21763
+ // Fragment navigation belongs to the browser: it owns the indicated
21764
+ // part of the document, and taking it over would cost `:target` and the
21765
+ // focus handling that come with it.
21766
+ if (isCurrent) {
21767
+ // Except this one, which the browser answers with a scroll and
21768
+ // nothing else: same pathname, same hash, so no event and no url
21769
+ // change reaches whoever is waiting on the designated element.
21770
+ rearmUrlTarget();
21771
+ }
21772
+ return;
21773
+ }
21774
+ // Nothing here declared a route, so there is nothing to route to: the
21775
+ // page is a plain document and a link in it is a plain link. Taking it
21776
+ // over anyway would push the url and then have nothing to show for it —
21777
+ // the address bar moves and the page does not (see applyRouting's own
21778
+ // "not called yet" branch, which is where that used to end up).
21779
+ if (!isRouting()) {
21780
+ return;
21781
+ }
21782
+ e.preventDefault();
21783
+ handleRoutingTask(href, {
21784
+ reason: `"click" on a[href="${href}"]`,
21785
+ navigationType: "push",
21786
+ });
21787
+ },
21788
+ { capture: true },
21789
+ );
21790
+
21791
+ window.addEventListener(
21792
+ "submit",
21793
+ () => {
21794
+ // Handle form submissions?
21795
+ // Not needed yet
21796
+ },
21797
+ { capture: true },
21798
+ );
21799
+
21800
+ window.addEventListener("popstate", (popstateEvent) => {
21801
+ const url = window.location.href;
21802
+ const state = popstateEvent.state;
21803
+ handleRoutingTask(url, {
21804
+ reason: `"popstate" event for ${url}`,
21805
+ navigationType: "traverse",
21806
+ state,
21807
+ });
21808
+ });
21809
+
21810
+ // A fragment navigation is left to the browser (see the click handler above):
21811
+ // it owns the indicated part of the document, and taking it over would cost
21812
+ // `:target` and the focus handling that come with it. The document url still
21813
+ // has to follow it — nothing else here would notice that it moved.
21814
+ window.addEventListener("hashchange", () => {
21815
+ updateDocumentUrl(window.location.href);
21816
+ });
21817
+
21818
+ const navTo = async (url, { replace, state } = {}) => {
21819
+ handleRoutingTask(url, {
21820
+ reason: `navTo called with "${url}"`,
21821
+ navigationType: replace ? "replace" : "push",
21822
+ state,
21823
+ });
21824
+ };
21825
+
21826
+ const stop = (reason = "stop called") => {
21827
+ triggerGlobalAbort(reason);
21828
+ };
21829
+
21830
+ const reload = () => {
21831
+ const url = window.location.href;
21832
+ const state = history.state;
21833
+ handleRoutingTask(url, {
21834
+ reason: "reload called",
21835
+ navigationType: "reload",
21836
+ state,
21837
+ });
21838
+ };
21839
+
21840
+ const navBack = () => {
21841
+ window.history.back();
21842
+ };
21843
+
21844
+ const navForward = () => {
21845
+ window.history.forward();
21846
+ };
21847
+
21848
+ const init = () => {
21849
+ const url = window.location.href;
21850
+ const state = history.state;
21851
+ handleRoutingTask(url, {
21852
+ reason: "routing initialization",
21853
+ navigationType: "load",
21854
+ state,
21855
+ });
21856
+ };
21857
+
21858
+ return {
21859
+ integration: "browser_history_api",
21860
+ init,
21861
+ navTo,
21862
+ stop,
21863
+ reload,
21864
+ navBack,
21865
+ navForward,
21866
+ getDocumentState,
21867
+ isVisited,
21868
+ visitedUrlsSignal,
21869
+ };
21870
+ };
21871
+
21872
+ let updateRoutes;
21873
+
21874
+ const applyActions = (params) => {
21875
+ const updateActionsResult = updateActions(params);
21876
+ const { allResult, runningActionSet } = updateActionsResult;
21877
+ const pendingTaskNameArray = [];
21878
+ for (const runningAction of runningActionSet) {
21879
+ pendingTaskNameArray.push(runningAction.name);
21880
+ }
21881
+ workingWhile(() => allResult, pendingTaskNameArray);
21882
+ return updateActionsResult;
21883
+ };
21884
+ const applyRouting = (
21885
+ url,
21886
+ {
21887
+ globalAbortSignal,
21888
+ abortSignal,
21889
+ // state
21890
+ navigationType,
21891
+ isVisited,
21892
+ reason,
21893
+ },
21894
+ ) => {
21895
+ if (!updateRoutes) {
21896
+ // .init() not called yet
21897
+ // likely because code does not uses routing at all
21898
+ return {};
21899
+ }
21900
+ const {
21901
+ loadSet,
21902
+ reloadSet,
21903
+ abortSignalMap,
21904
+ routeLoadRequestedMap,
21905
+ activeRouteSet,
21906
+ } = updateRoutes(url, {
21907
+ navigationType,
21908
+ isVisited,
21909
+ // state,
21910
+ });
21911
+ if (
21912
+ (!loadSet || loadSet.size === 0) &&
21913
+ (!reloadSet || reloadSet.size === 0)
21914
+ ) {
21915
+ return {
21916
+ allResult: undefined,
21917
+ requestedResult: undefined,
21918
+ activeRouteSet: new Set(),
21919
+ };
21920
+ }
21921
+ const updateActionsResult = updateActions({
21922
+ globalAbortSignal,
21923
+ abortSignal,
21924
+ runSet: loadSet,
21925
+ rerunSet: reloadSet,
21926
+ abortSignalMap,
21927
+ reason,
21928
+ isReplace: navigationType === "replace",
21929
+ });
21930
+ const { allResult, runningActionSet } = updateActionsResult;
21931
+ const pendingTaskNameArray = [];
21932
+ for (const [route, routeAction] of routeLoadRequestedMap) {
21933
+ if (runningActionSet.has(routeAction)) {
21934
+ pendingTaskNameArray.push(`${route.relativeUrl} -> ${routeAction.name}`);
21935
+ }
21936
+ }
21937
+ routingWhile(() => allResult, pendingTaskNameArray);
21938
+ return { ...updateActionsResult, activeRouteSet };
21939
+ };
21940
+
21941
+ const browserIntegration = setupBrowserIntegrationViaHistory({
21942
+ applyActions,
21943
+ applyRouting,
21944
+ // Routes are declared by the consumer and registered through
21945
+ // setOnAllRouteReady below, so "does this document route at all?" is only
21946
+ // answerable once that has run — hence a function, read at click time rather
21947
+ // than a value read at setup time.
21948
+ isRouting: () => Boolean(updateRoutes),
21949
+ });
21950
+
21951
+ setOnAllRouteReady((v) => {
21952
+ updateRoutes = v;
21953
+ browserIntegration.init();
21954
+ });
21955
+ setRouteIntegration(browserIntegration);
21956
+
21957
+ const navIntegratedVia = browserIntegration.integration;
21958
+ const navTo = (target, options) => {
21959
+ const url = new URL(target, window.location.href).href;
21960
+ const currentUrl = documentUrlSignal.peek();
21961
+ if (url === currentUrl) {
21962
+ if (options?.state === undefined) {
21963
+ return null;
21964
+ }
21965
+ // State-only update on same URL: skip if state is identical to current.
21966
+ const currentState = browserIntegration.getDocumentState();
21967
+ if (compareTwoJsValues(options.state, currentState)) {
21968
+ return null;
21969
+ }
21970
+ }
21971
+ return browserIntegration.navTo(url, options);
21972
+ };
21973
+ const stopLoad = (reason = "stopLoad() called") => {
21974
+ const windowIsLoading = windowIsLoadingSignal.value;
21975
+ if (windowIsLoading) {
21976
+ window.stop();
21977
+ }
21978
+ const documentIsBusy = documentIsBusySignal.value;
21979
+ if (documentIsBusy) {
21980
+ browserIntegration.stop(reason);
21981
+ }
21982
+ };
21983
+ const reload = browserIntegration.reload;
21984
+ const navBack = browserIntegration.navBack;
21985
+ const navForward = browserIntegration.navForward;
21986
+ const isVisited = browserIntegration.isVisited;
21987
+ const visitedUrlsSignal = browserIntegration.visitedUrlsSignal;
21988
+ browserIntegration.handleActionTask;
21989
+
21990
+ // Preact's own useId() (see preact/hooks) returns "P<mask0>-<mask1>", where
21991
+ // the mask is derived from render order within the nearest root/async
21992
+ // boundary — stable across re-renders of the *same* mount, but not across a
21993
+ // reload (render order can differ) or even across two mounts on the same
21994
+ // page (two components hitting useId() in the same relative order get the
21995
+ // same string). Storing one of these under type: "push" bakes it into a
21996
+ // history entry: reload the page and the entry's key may now belong to a
21997
+ // completely different component (or none), silently auto-opening whatever
21998
+ // happens to render at that same position instead.
21999
+ const PREACT_GENERATED_ID_REGEX = /^P\d+-\d+/;
22000
+ const isLikelyPreactGeneratedId = (id) => PREACT_GENERATED_ID_REGEX.test(id);
22001
+
22002
+ const NO_OP = () => {};
22003
+ const NO_ID_GIVEN = [undefined, NO_OP, NO_OP];
22004
+ const useNavStateBasic = (
22005
+ id,
22006
+ { debug, type = "replace", onLeave, defaultValue } = {},
22007
+ ) => {
22008
+ // Hooks must be called unconditionally — before the !id early return.
22009
+ const state = documentStateSignal.value;
22010
+ // Key presence is the flag — the value may be anything, including undefined.
22011
+ const keyInState = Boolean(id && state && Object.hasOwn(state, id));
22012
+ const onLeaveRef = useRef(onLeave);
22013
+ onLeaveRef.current = onLeave;
22014
+ const prevKeyInStateRef = useRef(keyInState);
22015
+ // enteredRef tracks whether enter() was called without a matching leave() yet.
22016
+ // It lets the effect distinguish an external disappearance (back button → fire onLeave)
22017
+ // from a programmatic one (leave() already set it to false before the state updates).
22018
+ const enteredRef = useRef(false);
22019
+ useEffect(() => {
22020
+ const prevKeyInState = prevKeyInStateRef.current;
22021
+ prevKeyInStateRef.current = keyInState;
22022
+ if (prevKeyInState && !keyInState && enteredRef.current) {
22023
+ enteredRef.current = false;
22024
+ onLeaveRef.current?.();
22025
+ }
22026
+ }, [keyInState]);
22027
+
22028
+ if (!id) {
22029
+ return NO_ID_GIVEN;
22030
+ }
22031
+
22032
+ let effectiveType = type;
22033
+ if (type === "push" && isLikelyPreactGeneratedId(id)) {
22034
+ effectiveType = "replace";
22035
+ }
22036
+
22037
+ const currentValue = keyInState ? state[id] : defaultValue;
22038
+
22039
+ if (debug) {
22040
+ console.debug(`useNavState(${id}) current value is ${currentValue}`);
22041
+ }
22042
+
22043
+ // enter(value): navigate TO this state (push or replace depending on type).
22044
+ // Calling enter() without a value stores "on" — the mere presence of the key
22045
+ // in the document state is enough to match; the value just allows associating
22046
+ // extra data with the entry when needed.
22047
+ const enter = (value = "on") => {
22048
+ enteredRef.current = true;
22049
+ const currentStateCopy = browserIntegration.getDocumentState() || {};
22050
+ if (Object.hasOwn(currentStateCopy, id) && currentStateCopy[id] === value) {
22051
+ return;
22052
+ }
22053
+ currentStateCopy[id] = value;
22054
+ navTo(window.location.href, {
22055
+ replace: effectiveType !== "push",
22056
+ state: currentStateCopy,
22057
+ });
22058
+ };
22059
+
22060
+ // leave(): navigate AWAY FROM this state (navBack in push mode, replace in replace mode).
22061
+ // isBack: when true (cancel close in push mode), call history.back() to restore the
22062
+ // pre-open state — discards any in-progress edits.
22063
+ // When false (confirmed close), replace the pushed entry instead: preserves the
22064
+ // current URL state (e.g. a new picker value) while removing the popup key.
22065
+ const leave = ({ isBack } = {}) => {
22066
+ enteredRef.current = false;
22067
+ const currentStateCopy = browserIntegration.getDocumentState() || {};
22068
+ if (!Object.hasOwn(currentStateCopy, id)) {
22069
+ return;
22070
+ }
22071
+ if (effectiveType === "push" && isBack) {
22072
+ browserIntegration.navBack();
22073
+ } else {
22074
+ delete currentStateCopy[id];
22075
+ navTo(window.location.href, {
22076
+ replace: true,
22077
+ state: currentStateCopy,
22078
+ });
22079
+ }
22080
+ };
22081
+
22082
+ return [currentValue, enter, leave];
22083
+ };
22084
+
22085
+ /**
22086
+ * Stores a named value in the browser's document state and returns it reactively.
22087
+ * The component re-renders whenever the value changes (navigation, back/forward button).
22088
+ *
22089
+ * @param {string} id
22090
+ * Unique key used to store the value in document state. Must be stable across renders.
22091
+ *
22092
+ * @param {object} [options]
22093
+ * @param {"push"|"replace"} [options.type="replace"]
22094
+ * Controls how enter() adds the state to browser history.
22095
+ * - "push": creates a new history entry — pressing the back button removes it and calls onLeave.
22096
+ * - "replace": updates the current history entry — no extra history entry is created.
22097
+ * Silently downgraded to "replace" (with a dev-only console.warn) when `id`
22098
+ * looks auto-generated (e.g. preact's own useId()) — an unstable id baked
22099
+ * into a pushed history entry won't survive a reload correctly, and could
22100
+ * even collide with a different component's own auto-generated id. Pass a
22101
+ * stable, explicit id to actually get "push" behavior.
22102
+ * @param {() => void} [options.onLeave]
22103
+ * Called when the state key disappears **externally** — e.g. the user presses the browser
22104
+ * back button. Not called when leave() is invoked programmatically.
22105
+ * @param {*} [options.defaultValue]
22106
+ * Value returned when `id` is absent from document state. Defaults to `undefined`.
22107
+ *
22108
+ * @returns {[value, enter, leave]}
22109
+ * - `value`: current value from document state, or `defaultValue` when the key is absent.
22110
+ * - `enter(value = "on")`: navigate TO this state (stores `value` under `id`).
22111
+ * Calling without an argument stores `"on"` — the presence of the key is enough to match;
22112
+ * the value allows associating extra data when needed.
22113
+ * - `leave()`: navigate AWAY FROM this state (removes `id` from document state,
22114
+ * or goes back in history when `type` is "push").
22115
+ */
22116
+ const useNavState = useNavStateBasic;
22117
+
21549
22118
  /**
21550
22119
  * @param {Element} element The element asking — the command's source, and the
21551
22120
  * anchor a popup opens on unless `anchor` says otherwise.
@@ -21953,15 +22522,21 @@ registerNaviCommand("--navi-send", (source, event) => {
21953
22522
  requester = firstButtonSubmitting;
21954
22523
  }
21955
22524
  }
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
22525
  // Nothing is committed when a constraint fails, so nothing is decided
21961
22526
  // and the popup must stay open — with the form still in front of the
21962
22527
  // user, showing what it is waiting for.
21963
22528
  let invalid = false;
22529
+ // What follows the send is read at the moment it runs, never before it:
22530
+ // it depends on the requester (Enter in a field sends through the first
22531
+ // submit button, and what follows is that button's answer), and on
22532
+ // anything the send itself decided — an action that learned where to go
22533
+ // from the response writes it on the form while it runs
22534
+ // (data-after-send), and this is what picks it up.
21964
22535
  const runAfterSend = () => {
22536
+ const afterSend = resolveAfterSend(target, requester);
22537
+ if (!afterSend) {
22538
+ return;
22539
+ }
21965
22540
  triggerNaviCommand(source, afterSend, event, { optional: true });
21966
22541
  };
21967
22542
  const {
@@ -21996,7 +22571,7 @@ registerNaviCommand("--navi-send", (source, event) => {
21996
22571
  requester,
21997
22572
  }),
21998
22573
  );
21999
- if (sent === false || invalid || !afterSend) {
22574
+ if (sent === false || invalid) {
22000
22575
  return sent;
22001
22576
  }
22002
22577
  if (isRunning) {
@@ -22188,6 +22763,30 @@ registerNaviCommand("--navi-back", (source, event) => {
22188
22763
  };
22189
22764
  });
22190
22765
 
22766
+ // Where a press takes the user. The destination is the command's argument
22767
+ // because it says WHAT the command does — "--navi-nav-to:/games/42" — which is how
22768
+ // it can also be what follows a form submission: the form has answered its
22769
+ // question, and the answer to "what now" is a page.
22770
+ //
22771
+ // A destination fixed at the call site, so it is for a page known before the
22772
+ // send — which is what a form needs, since it must also know where to go when
22773
+ // the press had nothing to send. A destination the response decides (a
22774
+ // creation, whose id comes back with it) is the action's own business: it
22775
+ // navigates itself.
22776
+ registerNaviCommand("--navi-nav-to", (source, event, { argument }) => {
22777
+ if (!argument) {
22778
+ console.warn(
22779
+ `[navi] "--navi-nav-to" needs a destination: --navi-nav-to:/the/url (relative to the current page, or absolute).`,
22780
+ );
22781
+ return undefined;
22782
+ }
22783
+ const target = resolveExplicitTarget(source) || source;
22784
+ return {
22785
+ target,
22786
+ implementation: () => navTo(argument),
22787
+ };
22788
+ });
22789
+
22191
22790
  registerNaviCommand("--navi-toggle", (source, event, { anchor } = {}) => {
22192
22791
  const target =
22193
22792
  resolveExplicitTarget(source) || resolveClosestExpandable(source);
@@ -24272,6 +24871,7 @@ const useUIGroupStateController = (
24272
24871
  pendingChangeRef.current = null;
24273
24872
  const batchedEvent = new CustomEvent(
24274
24873
  `${controlType}_batched_ui_state_update`,
24874
+ { detail: {} },
24275
24875
  );
24276
24876
  chainEvent(batchedEvent, pendingChange.e);
24277
24877
  scope._onChange(batchedEvent, {
@@ -28218,15 +28818,26 @@ const css$V = /* css */`
28218
28818
 
28219
28819
  Capping the *size* here rather than only offsetting the position is
28220
28820
  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
-
28821
+ free: --navi-app-width/--navi-app-height track the visual viewport, so
28822
+ the browser reflows the dialog itself as the keyboard opens.
28823
+
28824
+ A share of the app's own screen, not of the window (hence
28825
+ --navi-app-width rather than 3vvw): the gap must read as a small
28826
+ margin around the dialog, and 3% of a 1500px window is a 45px gap
28827
+ around a 600px app. Identical to 3vvw until the app declares
28828
+ --navi-app-max-width. */
28829
+ --x-dialog-container-spacing: calc(0.03 * var(--navi-app-width));
28830
+
28831
+ /* --navi-app-width, not --navi-vvw: a top-layer dialog is calibrated on
28832
+ the app's own screen, which is the viewport unless the app declared a
28833
+ narrower one (see navi_css_vars.js). An app-width cap alone never
28834
+ costs the gap below — it is subtracted from whichever of the two ends
28835
+ up smaller. */
28225
28836
  --dialog-maxmax-width: calc(
28226
- var(--navi-vvw) - 2 * var(--x-dialog-container-spacing)
28837
+ var(--navi-app-width) - 2 * var(--x-dialog-container-spacing)
28227
28838
  );
28228
28839
  --dialog-maxmax-height: calc(
28229
- var(--navi-vvh) - 2 * var(--x-dialog-container-spacing)
28840
+ var(--navi-app-height) - 2 * var(--x-dialog-container-spacing)
28230
28841
  );
28231
28842
 
28232
28843
  --dialog-border-radius: var(--navi-popup-border-radius);
@@ -28585,13 +29196,15 @@ const css$V = /* css */`
28585
29196
  * touch device.
28586
29197
  * @param {boolean} [props.expandY] - Same, vertically
28587
29198
  * (`--dialog-maxmax-height`).
28588
- * @param {string|number} [props.marginWithContainer="3vvw"] - Minimum gap kept
29199
+ * @param {string|number} [props.marginWithContainer="3appw"] - Minimum gap kept
28589
29200
  * between the dialog and the edges of its container, whatever its
28590
29201
  * `positionArea`: it both caps the dialog's own size (via
28591
29202
  * `--x-dialog-container-spacing`, written from this prop) and offsets a docked
28592
29203
  * 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
29204
+ * number of pixels, or a viewport length — "appw"/"apph" being the app's own
29205
+ * screen (the visual viewport, or the narrower one the app declared with
29206
+ * --navi-app-max-width) and "vvw"/"vvh" the visual viewport itself, which
29207
+ * shrinks when the mobile keyboard opens. Pass 0 for a dialog
28595
29208
  * meant to sit flush (a side panel).
28596
29209
  * @param {"close"|"cancel"|"capture"|"none"} [props.pointerInteractionOutsideEffect="close"]
28597
29210
  * - `"close"` closes the dialog on an outside click. `"capture"`/`"none"`
@@ -28892,11 +29505,12 @@ const useDialogProps = props => {
28892
29505
  const isDocked = dockedOnTouch && coarsePointerSignal.value;
28893
29506
  const positionArea = positionAreaProp ?? (isDocked ? DOCKED.positionArea : "center");
28894
29507
  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");
29508
+ // A share of whatever holds the dialog: the app's own screen for a
29509
+ // top-layer one — where appw is exactly "3% of the container", the
29510
+ // container being that screen (the viewport, unless the app declared a
29511
+ // narrower one) and the positioned ancestor for a local one, where
29512
+ // reading 3% of the screen gives an absurd gap inside a small box.
29513
+ isModal ? "3appw" : "3cqw");
28900
29514
  // "expand || expandX", the shorthand semantics Popup used to apply before
28901
29515
  // handing them over — the docked default only applies when neither was said
28902
29516
  const expandXUnset = expand === undefined && expandXProp === undefined;
@@ -29133,7 +29747,7 @@ const useDialogProps = props => {
29133
29747
  // A value only CSS could evaluate (a spacing token resolving to a var(),
29134
29748
  // a percentage…) — the placement below needs a real number, and letting
29135
29749
  // 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").`);
29750
+ 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
29751
  marginWithContainerInPixels = 0;
29138
29752
  }
29139
29753
  // The size caps read the same gap in CSS as the placement below applies
@@ -29560,8 +30174,10 @@ const css$U = /* css */`
29560
30174
  rather than a value so an outer component can bridge its own prop into
29561
30175
  --popover-max-height without having to restate 300px (see picker). */
29562
30176
  --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));
30177
+ /* --navi-app-*, not --navi-vvw/vvh: the app's own screen, which is the
30178
+ viewport unless the app declared a narrower one (navi_css_vars.js). */
30179
+ --popover-maxmax-height: calc(0.95 * var(--navi-app-height));
30180
+ --popover-maxmax-width: calc(0.95 * var(--navi-app-width));
29565
30181
 
29566
30182
  --popover-box-shadow: var(--navi-popup-box-shadow);
29567
30183
  --popover-border-radius: var(--navi-popup-border-radius);
@@ -36036,705 +36652,147 @@ const UITransition = ({
36036
36652
  debugContent,
36037
36653
  debugSize,
36038
36654
  disabled,
36039
- uiTransitionRef,
36040
- alignX,
36041
- alignY,
36042
- ...props
36043
- }) => {
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}`);
36655
+ uiTransitionRef,
36656
+ alignX,
36657
+ alignY,
36658
+ ...props
36659
+ }) => {
36660
+ const contentIdRef = useRef(contentId);
36661
+ const updateContentId = () => {
36662
+ const uiTransition = uiTransitionRef.current;
36663
+ if (!uiTransition) {
36664
+ return;
36334
36665
  }
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;
36666
+ const value = contentIdRef.current;
36667
+ uiTransition.updateContentId(value);
36352
36668
  };
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) {
36669
+ const uiTransitionContentIdContextValue = useMemo(() => {
36670
+ const set = new Set();
36671
+ const onSetChange = () => {
36672
+ const value = Array.from(set).join("|");
36673
+ contentIdRef.current = value;
36674
+ updateContentId();
36675
+ };
36676
+ const update = (part, newPart) => {
36677
+ if (!set.has(part)) {
36678
+ if (set.size === 0) {
36679
+ console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id set is empty`);
36680
+ return;
36681
+ }
36682
+ console.warn(`UITransition: content id update "${part}" -> "${newPart}" ignored because content id not found in set, only got [${Array.from(set).join(", ")}]`);
36371
36683
  return;
36372
36684
  }
36373
- if (linkElement.hasAttribute("data-readonly")) {
36685
+ set.delete(part);
36686
+ set.add(newPart);
36687
+ onSetChange();
36688
+ };
36689
+ const add = part => {
36690
+ if (!part) {
36374
36691
  return;
36375
36692
  }
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
36693
+ if (set.has(part)) {
36381
36694
  return;
36382
36695
  }
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
- }
36696
+ set.add(part);
36697
+ onSetChange();
36698
+ };
36699
+ const remove = part => {
36700
+ if (!part) {
36393
36701
  return;
36394
36702
  }
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()) {
36703
+ if (!set.has(part)) {
36401
36704
  return;
36402
36705
  }
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
- ) {
36706
+ set.delete(part);
36707
+ onSetChange();
36708
+ };
36536
36709
  return {
36537
- allResult: undefined,
36538
- requestedResult: undefined,
36539
- activeRouteSet: new Set(),
36710
+ add,
36711
+ update,
36712
+ remove
36540
36713
  };
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,
36714
+ }, []);
36715
+ const ref = useRef();
36716
+ const uiTransitionRefDefault = useRef();
36717
+ uiTransitionRef = uiTransitionRef || uiTransitionRefDefault;
36718
+ useLayoutEffect(() => {
36719
+ const uiTransition = createUITransitionController(ref.current, {
36720
+ alignX,
36721
+ alignY
36678
36722
  });
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];
36723
+ uiTransitionRef.current = uiTransition;
36724
+ return () => {
36725
+ uiTransition.cleanup();
36726
+ };
36727
+ }, [disabled, alignX, alignY]);
36728
+ return jsxs("div", {
36729
+ ref: ref,
36730
+ ...props,
36731
+ className: "ui_transition",
36732
+ "data-disabled": disabled ? "" : undefined,
36733
+ "data-transition-type": type,
36734
+ "data-transition-duration": duration,
36735
+ "data-debug-detection": debugDetection ? "" : undefined,
36736
+ "data-debug-size": debugSize ? "" : undefined,
36737
+ "data-debug-content": debugContent ? "" : undefined,
36738
+ children: [jsxs("div", {
36739
+ className: "ui_transition_active_group",
36740
+ children: [jsx("div", {
36741
+ className: "ui_transition_target_slot",
36742
+ "data-content-id": contentIdRef.current ? contentIdRef.current : undefined,
36743
+ children: jsx(UITransitionContentIdContext.Provider, {
36744
+ value: uiTransitionContentIdContextValue,
36745
+ children: children
36746
+ })
36747
+ }), jsx("div", {
36748
+ className: "ui_transition_outgoing_slot",
36749
+ inert: true
36750
+ })]
36751
+ }), jsxs("div", {
36752
+ className: "ui_transition_previous_group",
36753
+ inert: true,
36754
+ children: [jsx("div", {
36755
+ className: "ui_transition_previous_target_slot"
36756
+ }), jsx("div", {
36757
+ className: "ui_transition_previous_outgoing_slot"
36758
+ })]
36759
+ })]
36760
+ });
36704
36761
  };
36705
36762
 
36706
36763
  /**
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).
36764
+ * The goal of this hook is to allow a component to set a "content key"
36765
+ * Meaning all content within the component is identified by that key
36709
36766
  *
36710
- * @param {string} id
36711
- * Unique key used to store the value in document state. Must be stable across renders.
36767
+ * When the key changes, UITransition will be able to detect that and consider the content
36768
+ * as changed even if the component is still the same
36712
36769
  *
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`.
36770
+ * This is used by <Route> to set the content key to the route path
36771
+ * When the route becomes inactive it will call useUITransitionContentId(undefined)
36772
+ * And if a sibling route becones active it will call useUITransitionContentId with its own path
36728
36773
  *
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
36774
  */
36737
- const useNavState = useNavStateBasic;
36775
+ const useUITransitionContentId = value => {
36776
+ const contentId = useContext(UITransitionContentIdContext);
36777
+ const valueRef = useRef();
36778
+ if (contentId !== undefined && valueRef.current !== value) {
36779
+ const previousValue = valueRef.current;
36780
+ valueRef.current = value;
36781
+ if (previousValue === undefined) {
36782
+ contentId.add(value);
36783
+ } else {
36784
+ contentId.update(previousValue, value);
36785
+ }
36786
+ }
36787
+ useLayoutEffect(() => {
36788
+ if (contentId === undefined) {
36789
+ return null;
36790
+ }
36791
+ return () => {
36792
+ contentId.remove(valueRef.current);
36793
+ };
36794
+ }, []);
36795
+ };
36738
36796
 
36739
36797
  const NEVER_SET = {};
36740
36798
  const useUrlSearchParam = (paramName, defaultValue) => {
@@ -47865,21 +47923,51 @@ const withoutEmptyFields = uiState => {
47865
47923
  // register themselves in their own effects, which run first — this is the
47866
47924
  // earliest moment the form knows what it holds. Everything after this baseline
47867
47925
  // is a real send moving it forward (see useFormGroup's own onnavi_action_end).
47926
+ //
47927
+ // Taken a second time at the end of the tick, because "the earliest moment" is
47928
+ // not always late enough: a field that re-renders on its own schedule rather
47929
+ // than with the form — a row whose value is computed from signals, sitting
47930
+ // behind a memo — brings its value in a render of its own, which lands after
47931
+ // these effects. A form measured before it would open already changed, and
47932
+ // would never take the reference again. Both takes are the same arrival, so the
47933
+ // second one costs a render only when it moves something.
47868
47934
  const useHeldUIStateAsSent = (uiStateController, pristineKey) => {
47869
47935
  // The render that brought a new pristineKey read `changed` against the
47870
47936
  // previous baseline, and nothing else is going to move: the button would stay
47871
47937
  // 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.
47938
+ // render that reads the new baseline — the first take on mount has nobody to
47939
+ // tell, every field it is waiting for re-renders the form as it registers.
47874
47940
  const [, rereadBaseline] = useState(0);
47875
47941
  const isFirstRef = useRef(true);
47876
47942
  useLayoutEffect(() => {
47877
- uiStateController.sentUIState = readHeldUIState(uiStateController);
47878
- if (isFirstRef.current) {
47879
- isFirstRef.current = false;
47880
- return;
47881
- }
47882
- rereadBaseline(count => count + 1);
47943
+ const takeBaseline = () => {
47944
+ const baselineBefore = uiStateController.sentUIState;
47945
+ const baseline = readHeldUIState(uiStateController);
47946
+ uiStateController.sentUIState = baseline;
47947
+ return !compareTwoJsValues(baselineBefore, baseline);
47948
+ };
47949
+ const moved = takeBaseline();
47950
+ const isFirst = isFirstRef.current;
47951
+ isFirstRef.current = false;
47952
+ if (moved && !isFirst) {
47953
+ rereadBaseline(count => count + 1);
47954
+ }
47955
+ // A microtask, not a timeout: everything that belongs to this arrival —
47956
+ // the renders preact still has queued, the state they push into the form —
47957
+ // happens before the tick ends, and nothing a person does can land in
47958
+ // between.
47959
+ let abandoned = false;
47960
+ queueMicrotask(() => {
47961
+ if (abandoned) {
47962
+ return;
47963
+ }
47964
+ if (takeBaseline()) {
47965
+ rereadBaseline(count => count + 1);
47966
+ }
47967
+ });
47968
+ return () => {
47969
+ abandoned = true;
47970
+ };
47883
47971
  }, [uiStateController, pristineKey]);
47884
47972
  };
47885
47973
  const useUnregisteredControlWarning = ref => {