@jsenv/navi 0.29.398 → 0.29.400

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.
@@ -4,10 +4,10 @@
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createEventGroupLogger, createInternalCustomEvent, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, getScrollContainer, watchWheelTravel, scrollRoomTowards, isTouchDrivenEvent, scrollIntoViewScoped, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, trapScrollInside, parsePositionArea, snapToPixel, trapFocusInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
+ import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createEventGroupLogger, createInternalCustomEvent, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, getScrollContainer, watchWheelTravel, scrollRoomTowards, isTouchDrivenEvent, scrollIntoViewScoped, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, trapScrollInside, parsePositionArea, snapToPixel, trapFocusInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
- import { isValidElement, createContext, render, h, toChildArray, options, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
10
+ import { options, isValidElement, createContext, render, h, toChildArray, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
11
11
  import { useErrorBoundary, useLayoutEffect, useContext, useCallback, useRef, useState, useEffect, useMemo, useId } from "preact/hooks";
12
12
  import { humanizeI18n, prefixFirstAndIndentRemainingLines, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatTime, formatMinuteDuration, formatSecondDuration, formatHourDuration, formatTimeRelative, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
13
13
  export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
@@ -95,6 +95,29 @@ const css$17 = /* css */ `@layer navi {
95
95
  `;
96
96
  import.meta.css = [css$17, "@jsenv/navi/src/navi_z_indexes.js"];
97
97
 
98
+ /**
99
+ * A navigation is ABOUT to be applied — said before its very first write.
100
+ *
101
+ * Everything else a router says arrives once the change is made: a route
102
+ * announces that it matches, an action that it is running. That is too late for
103
+ * anyone who needs the page as it stands BEFORE, and the browser's view
104
+ * transitions are exactly that kind of reader — the picture they keep of the
105
+ * page being left is taken at the next frame, and a render answering a signal
106
+ * written a moment ago is already in the DOM by then (see route_travel.jsx).
107
+ *
108
+ * So this is the one moment where nothing has moved yet. It is published
109
+ * synchronously, from the top of the navigation, and whoever listens runs
110
+ * before the URL, the visited set, or any route has changed.
111
+ *
112
+ * The other end is published too, and for the same kind of reader: whoever
113
+ * held something across the change and has nobody to hand it to gets a moment
114
+ * to let go of it that does not depend on guessing how long the change takes.
115
+ */
116
+
117
+
118
+ const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
119
+ const [publishAfterRouting, observeAfterRouting] = createPubSub();
120
+
98
121
  const addIntoArray = (array, ...valuesToAdd) => {
99
122
  if (valuesToAdd.length === 1) {
100
123
  const [valueToAdd] = valuesToAdd;
@@ -325,6 +348,112 @@ const urlToScheme = (url) => {
325
348
  return scheme;
326
349
  };
327
350
 
351
+ /**
352
+ * The document's rendering, held for the one frame a view transition needs.
353
+ *
354
+ * The browser does not take the picture of the page being left when a
355
+ * transition is ASKED for — it takes it at the next frame, just before running
356
+ * the update callback. Preact renders sooner than that, in a microtask: so a
357
+ * change nobody asked for (a tab pressed, the back button) has already reached
358
+ * the DOM when the picture is taken, and the picture is of the page ARRIVING.
359
+ * Both sides of the animation then show it, and one watches a page slide onto
360
+ * itself.
361
+ *
362
+ * So what Preact has queued waits until the update callback, which is the
363
+ * moment the API is built around — the change belongs inside it. The whole
364
+ * document is held: it is about to be frozen under a picture anyway.
365
+ *
366
+ * ONE hold for the whole document, whoever animates. The hold is a wrapper
367
+ * around Preact's `options.debounceRendering`, and two of them installed
368
+ * independently restore each other in the wrong order when they let go — every
369
+ * render queued in between is then handed to a wrapper nobody will ever
370
+ * release. Everything that photographs a navigation (RouteTravel's box, a
371
+ * route transition) must therefore hold through this module, never through a
372
+ * wrapper of its own.
373
+ */
374
+
375
+
376
+ let renderingHold = null;
377
+ const holdRendering = () => {
378
+ if (renderingHold) {
379
+ return renderingHold.release;
380
+ }
381
+ const debounceRenderingBefore = options.debounceRendering;
382
+ const hold = {
383
+ render: null,
384
+ waiting: [],
385
+ release: () => {
386
+ // Only the hold that is still standing may be given back: a holder
387
+ // releasing after another has taken over must not let go of what it
388
+ // does not hold.
389
+ if (renderingHold !== hold) {
390
+ return;
391
+ }
392
+ renderingHold = null;
393
+ options.debounceRendering = debounceRenderingBefore;
394
+ const { render, waiting } = hold;
395
+ hold.render = null;
396
+ hold.waiting = [];
397
+ if (render) {
398
+ render();
399
+ }
400
+ for (const wait of waiting) {
401
+ wait();
402
+ }
403
+ },
404
+ };
405
+ renderingHold = hold;
406
+ options.debounceRendering = (render) => {
407
+ hold.render = render;
408
+ };
409
+ return hold.release;
410
+ };
411
+
412
+ // Anything else that must not happen before the picture is taken, and the
413
+ // scroll is the other one: a page one arrives at starts at its top, and the
414
+ // document put back to its top while the page being left is still on screen is
415
+ // a page that has ALREADY jumped when the picture is taken. Worse, the browser
416
+ // paints what the new offset shows and nothing else, so the picture keeps only
417
+ // the band it had already painted — the page being left is then seen in
418
+ // fragments, whatever the movement does afterwards.
419
+ //
420
+ // Run at once when nobody is photographing anything, which is the common case
421
+ // and must stay free.
422
+ const whenRenderingResumes = (callback) => {
423
+ if (!renderingHold) {
424
+ callback();
425
+ return;
426
+ }
427
+ renderingHold.waiting.push(callback);
428
+ };
429
+
430
+ // The hold a navigation takes on its way in — from before its first write,
431
+ // because by the time a route announces that it matches, Preact has already
432
+ // been told and the render is queued; a hold taken then is a hold taken too
433
+ // late. Kept here until whoever animates the change takes it over, or the
434
+ // navigation turns out to be one nobody animates.
435
+ let routingRenderingHold = null;
436
+ const holdRenderingForRouting = () => {
437
+ routingRenderingHold = holdRendering();
438
+ };
439
+ // Nobody had a picture to take: a page held for a change it does not animate
440
+ // is a page that stutters for nothing.
441
+ const releaseRoutingRenderingHold = () => {
442
+ const release = routingRenderingHold;
443
+ routingRenderingHold = null;
444
+ if (release) {
445
+ release();
446
+ }
447
+ };
448
+ // An animator takes the navigation's hold as its own — taking another would be
449
+ // taking a hold on a page that is holding still — or takes a fresh one when
450
+ // the change it animates is not a navigation.
451
+ const takeoverRoutingRenderingHold = () => {
452
+ const release = routingRenderingHold || holdRendering();
453
+ routingRenderingHold = null;
454
+ return release;
455
+ };
456
+
328
457
  installImportMetaCssBuild(import.meta);
329
458
 
330
459
  const URL_TARGET_ATTRIBUTE = "data-url-target";
@@ -374,7 +503,8 @@ let urlTargetOptions = {
374
503
  * How long, in ms, to keep waiting for a target that has not arrived, counted
375
504
  * from the moment the document stops working.
376
505
  * @param {number} [options.maxWait=10000]
377
- * Longest wait, in ms, for a document that never stops working.
506
+ * Longest wait, in ms, for a document that never stops working. The element
507
+ * is then answered as it stands, when it is there.
378
508
  */
379
509
  const setUrlTargetOptions = (options) => {
380
510
  urlTargetOptions = { ...urlTargetOptions, ...options };
@@ -401,6 +531,23 @@ const useUrlTargetId = () => {
401
531
  let stopWaitingForCurrentTarget = null;
402
532
  let currentTargetKey;
403
533
 
534
+ const documentLoadIsReturn = () => {
535
+ const [navigationEntry] = performance.getEntriesByType("navigation");
536
+ if (!navigationEntry) {
537
+ return false;
538
+ }
539
+ return (
540
+ navigationEntry.type === "reload" || navigationEntry.type === "back_forward"
541
+ );
542
+ };
543
+ // The url the document is on its way back to — a back, a forward, a reload —
544
+ // where the reader was, as opposed to a place just sent to. Said by the
545
+ // navigation before it writes anything, and by the load for the first url.
546
+ let returningToUrl = documentLoadIsReturn() ? window.location.href : null;
547
+ observeBeforeRouting(({ url, navigationType }) => {
548
+ returningToUrl = navigationType === "traverse" ? url : null;
549
+ });
550
+
404
551
  /**
405
552
  * Answers the URL's target again, as if it had just been designated.
406
553
  *
@@ -410,10 +557,10 @@ let currentTargetKey;
410
557
  */
411
558
  const rearmUrlTarget = () => {
412
559
  currentTargetKey = undefined;
413
- armUrlTarget(documentUrlSignal.peek());
560
+ armUrlTarget(documentUrlSignal.peek(), { isReturn: false });
414
561
  };
415
562
 
416
- const armUrlTarget = (documentUrl) => {
563
+ const armUrlTarget = (documentUrl, { isReturn }) => {
417
564
  const targetKey = urlToTargetKey(documentUrl);
418
565
  if (targetKey === currentTargetKey) {
419
566
  return;
@@ -424,11 +571,25 @@ const armUrlTarget = (documentUrl) => {
424
571
  stopWaitingForCurrentTarget = null;
425
572
  }
426
573
  const targetId = urlToTargetId(documentUrl);
574
+ // The browser leaves `:target` on the element a URL named once the URL has
575
+ // stopped naming it — a pushState never touches it.
576
+ const staleTarget = document.querySelector(":target");
577
+ if (staleTarget && staleTarget.id !== targetId) {
578
+ writeTarget(null);
579
+ }
427
580
  if (!targetId) {
428
581
  return;
429
582
  }
430
- stopWaitingForCurrentTarget = waitForElementWithId(targetId, (element) => {
583
+ stopWaitingForCurrentTarget = waitForUrlTarget(targetId, (element) => {
431
584
  stopWaitingForCurrentTarget = null;
585
+ if (!element.matches(":target")) {
586
+ writeTarget(element);
587
+ }
588
+ if (isReturn) {
589
+ // The offset put back by scroll_restoration.js is the reader's own: the
590
+ // element is named by the URL, not arrived at.
591
+ return;
592
+ }
432
593
  revealUrlTarget(element);
433
594
  });
434
595
  };
@@ -445,14 +606,27 @@ const urlToTargetId = (url) => {
445
606
  return hash ? decodeURIComponent(hash.slice(1)) : "";
446
607
  };
447
608
 
448
- const waitForElementWithId = (id, onFound) => {
609
+ // Calls `onSettled` with the element once it can be answered: it exists, it
610
+ // shows something, and the document is done working. Found is not enough while
611
+ // the document works — what sits above it may still be drawn and push it down,
612
+ // and a page reusing a node of the page being left holds it before its own
613
+ // data has come.
614
+ //
615
+ // Confirmed at the next frame, once rendering has resumed: the render the last
616
+ // answer asked for has run by then, and a component starting its own run from
617
+ // that render has made the document busy again.
618
+ const waitForUrlTarget = (id, onSettled) => {
619
+ const { graceAfterIdle, maxWait } = urlTargetOptions;
620
+ let stopped = false;
621
+ let confirmationPending = false;
622
+ let frame = null;
449
623
  let mutationObserver = null;
450
624
  let stopWatchingBusy = null;
451
625
  let idleTimeout = null;
452
626
  let maxWaitTimeout = null;
453
- let found = false;
454
627
 
455
628
  const stopWaiting = () => {
629
+ stopped = true;
456
630
  if (mutationObserver) {
457
631
  mutationObserver.disconnect();
458
632
  mutationObserver = null;
@@ -463,76 +637,214 @@ const waitForElementWithId = (id, onFound) => {
463
637
  }
464
638
  clearTimeout(idleTimeout);
465
639
  clearTimeout(maxWaitTimeout);
640
+ if (frame !== null) {
641
+ cancelAnimationFrame(frame);
642
+ frame = null;
643
+ }
466
644
  };
467
645
 
468
- const checkForElement = () => {
646
+ const findElement = () => {
469
647
  const element = document.getElementById(id);
470
648
  if (!element) {
471
- return;
649
+ return null;
472
650
  }
473
651
  // Rendered inside a closed tab, a folded details, a view that is not the
474
- // one on screen: the element exists but would show nothing. Keep waiting —
475
- // it is the same wait, for the same reason.
652
+ // one on screen: the element exists but would show nothing.
476
653
  if (element.checkVisibility && !element.checkVisibility()) {
477
- return;
654
+ return null;
478
655
  }
479
- found = true;
656
+ return element;
657
+ };
658
+ const settle = (element) => {
480
659
  stopWaiting();
481
- onFound(element);
660
+ onSettled(element);
661
+ };
662
+ const check = () => {
663
+ if (confirmationPending || documentIsBusySignal.peek() || !findElement()) {
664
+ return;
665
+ }
666
+ confirmationPending = true;
667
+ whenRenderingResumes(() => {
668
+ if (stopped) {
669
+ return;
670
+ }
671
+ frame = requestAnimationFrame(() => {
672
+ frame = null;
673
+ confirmationPending = false;
674
+ if (documentIsBusySignal.peek()) {
675
+ return;
676
+ }
677
+ const element = findElement();
678
+ if (element) {
679
+ settle(element);
680
+ }
681
+ });
682
+ });
482
683
  };
483
684
 
484
- checkForElement();
485
- if (found) {
486
- return stopWaiting;
487
- }
488
-
489
- mutationObserver = new MutationObserver(checkForElement);
685
+ mutationObserver = new MutationObserver(check);
686
+ // Every attribute, not only `id`: what shows an element that is already
687
+ // there — a <details> opening, a tab panel losing its display: none — is an
688
+ // attribute written somewhere above it, and nothing else says so.
490
689
  mutationObserver.observe(document.documentElement, {
491
690
  childList: true,
492
691
  subtree: true,
493
692
  attributes: true,
494
- attributeFilter: ["id"],
495
693
  });
496
- const { graceAfterIdle, maxWait } = urlTargetOptions;
497
694
  stopWatchingBusy = effect(() => {
498
695
  const documentIsBusy = documentIsBusySignal.value;
499
696
  clearTimeout(idleTimeout);
500
- if (!documentIsBusy) {
501
- idleTimeout = setTimeout(stopWaiting, graceAfterIdle);
697
+ if (documentIsBusy) {
698
+ return;
502
699
  }
503
- });
504
- maxWaitTimeout = setTimeout(stopWaiting, maxWait);
700
+ check();
701
+ // The grace is for an element that has not come; one already there and
702
+ // waiting for rendering to resume is not given up on.
703
+ idleTimeout = setTimeout(() => {
704
+ if (!confirmationPending) {
705
+ stopWaiting();
706
+ }
707
+ }, graceAfterIdle);
708
+ });
709
+ // A document that never stops working: the element as it stands, when it is
710
+ // there at all.
711
+ maxWaitTimeout = setTimeout(() => {
712
+ const element = findElement();
713
+ if (element) {
714
+ settle(element);
715
+ } else {
716
+ stopWaiting();
717
+ }
718
+ }, maxWait);
505
719
 
506
720
  return stopWaiting;
507
721
  };
508
722
 
509
723
  const revealUrlTarget = (element) => {
510
724
  const { block, behavior, markDuration } = urlTargetOptions;
511
- // The element just entered the DOM: where it sits is only known once layout
512
- // has run.
513
- requestAnimationFrame(() => {
514
- const prefersReducedMotion = window.matchMedia(
515
- "(prefers-reduced-motion: reduce)",
516
- ).matches;
517
- element.scrollIntoView({
518
- block,
519
- behavior: prefersReducedMotion ? "instant" : behavior,
520
- });
521
- // What the browser does when it handles a fragment itself: keyboard
522
- // navigation resumes from the target, not from the top of the document.
523
- if (elementIsFocusable(element)) {
524
- element.focus({ preventScroll: true });
725
+ const prefersReducedMotion = window.matchMedia(
726
+ "(prefers-reduced-motion: reduce)",
727
+ ).matches;
728
+ element.scrollIntoView({
729
+ block,
730
+ behavior: prefersReducedMotion ? "instant" : behavior,
731
+ });
732
+ // What the browser does when it handles a fragment itself: keyboard
733
+ // navigation resumes from the target, not from the top of the document.
734
+ if (elementIsFocusable(element)) {
735
+ element.focus({ preventScroll: true });
736
+ }
737
+ element.setAttribute(URL_TARGET_ATTRIBUTE, "");
738
+ setTimeout(() => {
739
+ element.removeAttribute(URL_TARGET_ATTRIBUTE);
740
+ }, markDuration);
741
+ };
742
+
743
+ let targetWriteInProgress = false;
744
+ /**
745
+ * True while navi writes the browser's `:target`. The fragment navigation that
746
+ * takes is announced like any other — a popstate, a navigate event, an entry
747
+ * change — and names the address and the state the document is already at, so
748
+ * whoever routes on those events leaves it alone.
749
+ */
750
+ const isTargetWriteInProgress = () => {
751
+ return targetWriteInProgress;
752
+ };
753
+
754
+ // `:target` onto `element`, or off whatever holds it when `element` is null.
755
+ //
756
+ // A replace towards the address the browser is at: the browser answers it as a
757
+ // fragment navigation, and that is the only moment it sets `:target`. It also
758
+ // scrolls to what the fragment designates, resets the entry's states
759
+ // (history.state in Firefox, the Navigation API state everywhere) and fires a
760
+ // popstate (Chrome, Safari) and a navigate event. The address, both states and
761
+ // the scroll are put back in the same task, so nothing of it is painted; a
762
+ // running view transition is not skipped by it (measured in Chrome, Firefox
763
+ // and Safari).
764
+ const writeTarget = (element) => {
765
+ const address = window.location.href;
766
+ const historyState = window.history.state;
767
+ const { navigation } = window;
768
+ const entry = navigation ? navigation.currentEntry : null;
769
+ const entryState = entry ? entry.getState() : undefined;
770
+ const putScrollBack = captureScroll(element);
771
+ const addressFragment = new URL(address).hash.slice(1);
772
+ const addressTargetId = decodeURIComponent(addressFragment);
773
+
774
+ let fragmentUrl;
775
+ let silencedElement = null;
776
+ if (element) {
777
+ fragmentUrl =
778
+ element.id === addressTargetId
779
+ ? address
780
+ : `${urlWithoutFragment(address)}#${encodeURIComponent(element.id)}`;
781
+ } else if (addressFragment) {
782
+ // The address itself, so the fragment does not change and no hashchange
783
+ // follows; the element it names briefly without its id, so it designates
784
+ // nothing.
785
+ fragmentUrl = address;
786
+ silencedElement = document.getElementById(addressTargetId);
787
+ } else {
788
+ // An empty fragment designates the top of the document; the scroll it
789
+ // makes is put back with the others.
790
+ fragmentUrl = `${address}#`;
791
+ }
792
+
793
+ targetWriteInProgress = true;
794
+ try {
795
+ if (silencedElement) {
796
+ silencedElement.removeAttribute("id");
525
797
  }
526
- element.setAttribute(URL_TARGET_ATTRIBUTE, "");
527
- setTimeout(() => {
528
- element.removeAttribute(URL_TARGET_ATTRIBUTE);
529
- }, markDuration);
798
+ window.location.replace(fragmentUrl);
799
+ } finally {
800
+ if (silencedElement) {
801
+ silencedElement.id = addressTargetId;
802
+ }
803
+ window.history.replaceState(historyState, null, address);
804
+ if (entry && navigation.currentEntry) {
805
+ navigation.updateCurrentEntry({ state: entryState });
806
+ }
807
+ targetWriteInProgress = false;
808
+ }
809
+ putScrollBack();
810
+ };
811
+
812
+ // Every box a fragment navigation towards `element` may scroll: its ancestors,
813
+ // and the document.
814
+ const captureScroll = (element) => {
815
+ const boxes = [];
816
+ let box = element ? element.parentElement : null;
817
+ while (box) {
818
+ boxes.push(box);
819
+ box = box.parentElement;
820
+ }
821
+ const { scrollingElement } = document;
822
+ if (!boxes.includes(scrollingElement)) {
823
+ boxes.push(scrollingElement);
824
+ }
825
+ const positions = boxes.map((box) => {
826
+ return { box, left: box.scrollLeft, top: box.scrollTop };
530
827
  });
828
+ return () => {
829
+ for (const { box, left, top } of positions) {
830
+ if (box.scrollLeft !== left || box.scrollTop !== top) {
831
+ box.scrollTo({ left, top, behavior: "instant" });
832
+ }
833
+ }
834
+ };
835
+ };
836
+
837
+ const urlWithoutFragment = (url) => {
838
+ const urlObject = new URL(url);
839
+ urlObject.hash = "";
840
+ return urlObject.href;
531
841
  };
532
842
 
533
843
  effect(() => {
534
844
  const documentUrl = documentUrlSignal.value;
535
- armUrlTarget(documentUrl);
845
+ const isReturn = returningToUrl === documentUrl;
846
+ returningToUrl = null;
847
+ armUrlTarget(documentUrl, { isReturn });
536
848
  });
537
849
 
538
850
  /**
@@ -10437,6 +10749,26 @@ const dispatchRequestInteraction = (
10437
10749
  });
10438
10750
  };
10439
10751
 
10752
+ /**
10753
+ * Whether the control `element` belongs to lets an interaction of this intent
10754
+ * through, asked ahead of the event the gate will judge, and silently: nothing
10755
+ * is reported, nothing runs. For what a press is about to lead to (see
10756
+ * announceOpeningPress in commands.js) — the press itself is not that event: a
10757
+ * drag source cancels a `pointerdown` whose click still comes and opens.
10758
+ *
10759
+ * @param {Element} element
10760
+ * @param {{ intent: "read" | "write" }} options
10761
+ * @returns {boolean}
10762
+ */
10763
+ const allowsInteraction = (element, { intent }) => {
10764
+ const controlHost = findControlHost(element) || element;
10765
+ const ci = controlHost.__uiStateController__?.rules.interaction;
10766
+ if (!ci) {
10767
+ return true;
10768
+ }
10769
+ return ci.checkInteractivity({ intent });
10770
+ };
10771
+
10440
10772
  const onRequestInteraction = (
10441
10773
  requestInteractionCustomEvent,
10442
10774
  { debugInteraction },
@@ -18348,6 +18680,33 @@ const syncOwnedResourceToSignals = (
18348
18680
 
18349
18681
  // Global signal registry for route template detection
18350
18682
  const globalSignalRegistry = new Map();
18683
+
18684
+ // The values some states are about to hold, answered in place of what they
18685
+ // hold for the length of one readStateAsIf.
18686
+ let valuesAsIf = null;
18687
+ /**
18688
+ * Runs `read` as if each state in `values` held the value given for it: the
18689
+ * address a press is about to write, read before it is written. Nothing is
18690
+ * written, so whoever watches those states hears nothing, and `read`
18691
+ * subscribes to nothing.
18692
+ *
18693
+ * Only a read of the state itself is answered this way. A `computed` derived
18694
+ * from one answers what it last computed — or, evaluated for the first time
18695
+ * inside `read`, caches a value derived from this answer: `read` reads the
18696
+ * states it depends on directly (a route action's params do).
18697
+ *
18698
+ * @param {Map<import("@preact/signals").Signal, any>} values
18699
+ * @param {() => any} read
18700
+ */
18701
+ const readStateAsIf = (values, read) => {
18702
+ const previous = valuesAsIf;
18703
+ valuesAsIf = values;
18704
+ try {
18705
+ return untracked(read);
18706
+ } finally {
18707
+ valuesAsIf = previous;
18708
+ }
18709
+ };
18351
18710
  let signalIdCounter = 0;
18352
18711
  const generateSignalId = () => {
18353
18712
  const id = signalIdCounter++;
@@ -18671,6 +19030,9 @@ const stateSignal = (defaultValue, options = {}) => {
18671
19030
  const valueDescriptor = Object.getOwnPropertyDescriptor(signalProto, "value");
18672
19031
  Object.defineProperty(preactSignal, "value", {
18673
19032
  get() {
19033
+ if (valuesAsIf !== null && valuesAsIf.has(preactSignal)) {
19034
+ return valuesAsIf.get(preactSignal);
19035
+ }
18674
19036
  return valueDescriptor.get.call(preactSignal);
18675
19037
  },
18676
19038
  set(newValue) {
@@ -27055,7 +27417,8 @@ const route = (
27055
27417
  };
27056
27418
  // Asks ahead of the arrival for what this route's actions ask without the
27057
27419
  // address — a page's code, a read without params (see routeAction). An
27058
- // action keyed by a param waits for the arrival: a prefetch has no address.
27420
+ // action keyed by a param waits for the arrival: a prefetch has no address
27421
+ // (preloadState is the one that has one).
27059
27422
  route.preload = () => {
27060
27423
  const preloadSet = routePreloadMap.get(route);
27061
27424
  if (!preloadSet) {
@@ -27635,6 +27998,40 @@ const preloadUrl = (url) => {
27635
27998
  }
27636
27999
  }
27637
28000
  };
28001
+
28002
+ const routeStatePreloadMap = new WeakMap();
28003
+ const registerRouteStatePreload = (route, preload) => {
28004
+ let preloadSet = routeStatePreloadMap.get(route);
28005
+ if (!preloadSet) {
28006
+ preloadSet = new Set();
28007
+ routeStatePreloadMap.set(route, preloadSet);
28008
+ }
28009
+ preloadSet.add(preload);
28010
+ };
28011
+ /**
28012
+ * Preloads what the routes one is on will ask for once `signal` holds
28013
+ * `value`: the address a popup's opening is about to write (see
28014
+ * open_controller.js), known at the press that opens it. Only the routes
28015
+ * matching now are asked — writing a state keeps one on the same page.
28016
+ */
28017
+ const preloadState = (signal, value) => {
28018
+ if (!activeRouteSet) {
28019
+ return;
28020
+ }
28021
+ const values = new Map([[signal, value]]);
28022
+ for (const route of activeRouteSet) {
28023
+ if (!route.matchingSignal.peek()) {
28024
+ continue;
28025
+ }
28026
+ const preloadSet = routeStatePreloadMap.get(route);
28027
+ if (!preloadSet) {
28028
+ continue;
28029
+ }
28030
+ for (const preload of preloadSet) {
28031
+ preload(values);
28032
+ }
28033
+ }
28034
+ };
27638
28035
  const setupRoutes = (routes) => {
27639
28036
  if (setupRoutesCalled) {
27640
28037
  throw new Error(
@@ -28228,112 +28625,6 @@ const dropGeneratedIdKeys = (state) => {
28228
28625
  return stateWithoutGeneratedIdKeys;
28229
28626
  };
28230
28627
 
28231
- /**
28232
- * The document's rendering, held for the one frame a view transition needs.
28233
- *
28234
- * The browser does not take the picture of the page being left when a
28235
- * transition is ASKED for — it takes it at the next frame, just before running
28236
- * the update callback. Preact renders sooner than that, in a microtask: so a
28237
- * change nobody asked for (a tab pressed, the back button) has already reached
28238
- * the DOM when the picture is taken, and the picture is of the page ARRIVING.
28239
- * Both sides of the animation then show it, and one watches a page slide onto
28240
- * itself.
28241
- *
28242
- * So what Preact has queued waits until the update callback, which is the
28243
- * moment the API is built around — the change belongs inside it. The whole
28244
- * document is held: it is about to be frozen under a picture anyway.
28245
- *
28246
- * ONE hold for the whole document, whoever animates. The hold is a wrapper
28247
- * around Preact's `options.debounceRendering`, and two of them installed
28248
- * independently restore each other in the wrong order when they let go — every
28249
- * render queued in between is then handed to a wrapper nobody will ever
28250
- * release. Everything that photographs a navigation (RouteTravel's box, a
28251
- * route transition) must therefore hold through this module, never through a
28252
- * wrapper of its own.
28253
- */
28254
-
28255
-
28256
- let renderingHold = null;
28257
- const holdRendering = () => {
28258
- if (renderingHold) {
28259
- return renderingHold.release;
28260
- }
28261
- const debounceRenderingBefore = options.debounceRendering;
28262
- const hold = {
28263
- render: null,
28264
- waiting: [],
28265
- release: () => {
28266
- // Only the hold that is still standing may be given back: a holder
28267
- // releasing after another has taken over must not let go of what it
28268
- // does not hold.
28269
- if (renderingHold !== hold) {
28270
- return;
28271
- }
28272
- renderingHold = null;
28273
- options.debounceRendering = debounceRenderingBefore;
28274
- const { render, waiting } = hold;
28275
- hold.render = null;
28276
- hold.waiting = [];
28277
- if (render) {
28278
- render();
28279
- }
28280
- for (const wait of waiting) {
28281
- wait();
28282
- }
28283
- },
28284
- };
28285
- renderingHold = hold;
28286
- options.debounceRendering = (render) => {
28287
- hold.render = render;
28288
- };
28289
- return hold.release;
28290
- };
28291
-
28292
- // Anything else that must not happen before the picture is taken, and the
28293
- // scroll is the other one: a page one arrives at starts at its top, and the
28294
- // document put back to its top while the page being left is still on screen is
28295
- // a page that has ALREADY jumped when the picture is taken. Worse, the browser
28296
- // paints what the new offset shows and nothing else, so the picture keeps only
28297
- // the band it had already painted — the page being left is then seen in
28298
- // fragments, whatever the movement does afterwards.
28299
- //
28300
- // Run at once when nobody is photographing anything, which is the common case
28301
- // and must stay free.
28302
- const whenRenderingResumes = (callback) => {
28303
- if (!renderingHold) {
28304
- callback();
28305
- return;
28306
- }
28307
- renderingHold.waiting.push(callback);
28308
- };
28309
-
28310
- // The hold a navigation takes on its way in — from before its first write,
28311
- // because by the time a route announces that it matches, Preact has already
28312
- // been told and the render is queued; a hold taken then is a hold taken too
28313
- // late. Kept here until whoever animates the change takes it over, or the
28314
- // navigation turns out to be one nobody animates.
28315
- let routingRenderingHold = null;
28316
- const holdRenderingForRouting = () => {
28317
- routingRenderingHold = holdRendering();
28318
- };
28319
- // Nobody had a picture to take: a page held for a change it does not animate
28320
- // is a page that stutters for nothing.
28321
- const releaseRoutingRenderingHold = () => {
28322
- const release = routingRenderingHold;
28323
- routingRenderingHold = null;
28324
- if (release) {
28325
- release();
28326
- }
28327
- };
28328
- // An animator takes the navigation's hold as its own — taking another would be
28329
- // taking a hold on a page that is holding still — or takes a fresh one when
28330
- // the change it animates is not a navigation.
28331
- const takeoverRoutingRenderingHold = () => {
28332
- const release = routingRenderingHold || holdRendering();
28333
- routingRenderingHold = null;
28334
- return release;
28335
- };
28336
-
28337
28628
  /**
28338
28629
  * Where a page was left, given back when one comes back to it.
28339
28630
  *
@@ -28534,6 +28825,11 @@ const restoreScrollPosition = (url) => {
28534
28825
  // So `from`, the url being left, tells an arrival from the same place said
28535
28826
  // differently, and only the first is moved.
28536
28827
  //
28828
+ // A fragment does not make it less of an arrival. The browser finds nothing to
28829
+ // scroll to after a pushState; url_target.js brings the element in once it
28830
+ // renders, from this top — and when it never comes, the page is read from its
28831
+ // top like any other.
28832
+ //
28537
28833
  // The document, because the document is the scrollport in the common case. An
28538
28834
  // app that scrolls an element of its own scrolls it itself.
28539
28835
  const startAtTop = (url, { from } = {}) => {
@@ -28543,14 +28839,10 @@ const startAtTop = (url, { from } = {}) => {
28543
28839
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
28544
28840
  };
28545
28841
  const isArrival = (url, { from }) => {
28546
- const urlObject = new URL(url, window.location.href);
28547
- // A fragment names where to land, and the browser is the one that finds it.
28548
- if (urlObject.hash) {
28549
- return false;
28550
- }
28551
28842
  if (
28552
28843
  from !== undefined &&
28553
- new URL(from, window.location.href).pathname === urlObject.pathname
28844
+ new URL(from, window.location.href).pathname ===
28845
+ new URL(url, window.location.href).pathname
28554
28846
  ) {
28555
28847
  return false;
28556
28848
  }
@@ -28627,29 +28919,6 @@ const forgetScrollerUnlessPageLeft = (name) => {
28627
28919
  }
28628
28920
  };
28629
28921
 
28630
- /**
28631
- * A navigation is ABOUT to be applied — said before its very first write.
28632
- *
28633
- * Everything else a router says arrives once the change is made: a route
28634
- * announces that it matches, an action that it is running. That is too late for
28635
- * anyone who needs the page as it stands BEFORE, and the browser's view
28636
- * transitions are exactly that kind of reader — the picture they keep of the
28637
- * page being left is taken at the next frame, and a render answering a signal
28638
- * written a moment ago is already in the DOM by then (see route_travel.jsx).
28639
- *
28640
- * So this is the one moment where nothing has moved yet. It is published
28641
- * synchronously, from the top of the navigation, and whoever listens runs
28642
- * before the URL, the visited set, or any route has changed.
28643
- *
28644
- * The other end is published too, and for the same kind of reader: whoever
28645
- * held something across the change and has nobody to hand it to gets a moment
28646
- * to let go of it that does not depend on guessing how long the change takes.
28647
- */
28648
-
28649
-
28650
- const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
28651
- const [publishAfterRouting, observeAfterRouting] = createPubSub();
28652
-
28653
28922
  const getHrefTargetInfo = (href) => {
28654
28923
  href = String(href);
28655
28924
 
@@ -29157,6 +29426,11 @@ const setupBrowserIntegrationViaHistory = ({
29157
29426
  });
29158
29427
 
29159
29428
  window.addEventListener("popstate", (popstateEvent) => {
29429
+ if (isTargetWriteInProgress()) {
29430
+ // The fragment navigation navi makes to set `:target` (see
29431
+ // url_target.js): the entry keeps its address and its state.
29432
+ return;
29433
+ }
29160
29434
  // The entry a pending replace was for is no longer the current one, and
29161
29435
  // the History API has no way to write it (see replaceAddressWhenSettled).
29162
29436
  dropPendingReplace();
@@ -29232,9 +29506,14 @@ const setupBrowserIntegrationViaHistory = ({
29232
29506
  // one), so whoever awaits this reads a document url and state that
29233
29507
  // already say where it landed.
29234
29508
  const landedPromise = new Promise((resolve) => {
29235
- window.addEventListener("popstate", () => resolve(true), {
29236
- once: true,
29237
- });
29509
+ const onPopstate = () => {
29510
+ if (isTargetWriteInProgress()) {
29511
+ return;
29512
+ }
29513
+ window.removeEventListener("popstate", onPopstate);
29514
+ resolve(true);
29515
+ };
29516
+ window.addEventListener("popstate", onPopstate);
29238
29517
  });
29239
29518
  landOnPending = landOn || null;
29240
29519
  window.history.back();
@@ -34226,10 +34505,15 @@ const RouteTravel = ({
34226
34505
  // stopping. Which end decides how it is answered, and the two are not the
34227
34506
  // same amount of work.
34228
34507
  onEdge: ({
34229
- sign
34508
+ sign,
34509
+ thrown
34230
34510
  }) => {
34231
34511
  const travel = travelRef.current;
34232
- if (!travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
34512
+ if (
34513
+ // Thrown on before its end: handing over from there would mean jumping
34514
+ // the pictures the rest of the way first (see scrubTravel below), a
34515
+ // page seen leaping. The travel caught simply arrives.
34516
+ thrown || !travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
34233
34517
  return false;
34234
34518
  }
34235
34519
  // Where the page in hand is coming from, said the way the gesture says
@@ -34806,10 +35090,14 @@ const writeCover = (edge, element, size) => {
34806
35090
  * the address, which is also what makes it worth fetching AHEAD of the
34807
35091
  * arrival: a link to the route preruns it when the pointer or the focus
34808
35092
  * reaches the link (see docs/dynamic_import.md). An action whose params
34809
- * come from the address is never prefetched — a prefetch has no address.
35093
+ * come from the address is not prefetched by a link — a link's prefetch has
35094
+ * no address. It is by the press that opens a popup bound to a state these
35095
+ * params read: that address is the current one with the state set to the
35096
+ * popup's value (see `prefetch` on Button).
34810
35097
  * @param {object} [options]
34811
- * @param {boolean} [options.prefetch=true] - `false` keeps a param-less action
34812
- * from being prerun on intent, when the read is not worth a hover.
35098
+ * @param {boolean} [options.prefetch=true] - `false` keeps the action from
35099
+ * being prerun ahead of what asks for it, when the read is not worth a
35100
+ * hover or a press that may not end in an opening.
34813
35101
  */
34814
35102
  const routeAction = (
34815
35103
  routeOrRoutes,
@@ -34838,27 +35126,47 @@ const routeAction = (
34838
35126
  },
34839
35127
  options,
34840
35128
  );
34841
- if (!paramsEffect && prefetch) {
35129
+ if (prefetch) {
34842
35130
  for (const route of routes) {
34843
- registerRoutePreload(route, () => {
34844
- prefetchParamless(action);
34845
- });
35131
+ if (paramsEffect) {
35132
+ registerRouteStatePreload(route, (values) => {
35133
+ prefetchAsIf(action, paramsEffect, values);
35134
+ });
35135
+ } else {
35136
+ registerRoutePreload(route, () => {
35137
+ prerunAhead(action.bindParams(true));
35138
+ });
35139
+ }
34846
35140
  }
34847
35141
  }
34848
35142
  return actionBoundToRoute;
34849
35143
  };
34850
35144
 
34851
- // The instance the effect above runs on the match is the one bound to `true`
34852
- // (the default params), so a prerun here is what the arrival promotes to a
34853
- // run. A prefetch that fails is not reported: nothing on screen asked for it,
34854
- // and the failure stays on the instance, where a run asks again — a FAILED
34855
- // action is run, only RUNNING and COMPLETED are left alone — and the arrival
34856
- // shows its own. It is left FAILED on purpose, not reset: the press that
34857
- // brings the arrival also focuses the link, so a prerun can be in flight when
34858
- // the arrival promotes it, and a reset landing after that would pull the
34859
- // answer from under the page that is reading it.
34860
- const prefetchParamless = (action) => {
34861
- const instance = action.bindParams(true);
35145
+ // The params as they will read once the states hold what they are about to
35146
+ // (see readStateAsIf), prerun only when that changes them: params the state
35147
+ // leaves alone are the ones the action already runs on — or is meant not to,
35148
+ // and a prerun would retry a FAILED one behind the screen's back.
35149
+ const prefetchAsIf = (action, paramsEffect, values) => {
35150
+ const params = readStateAsIf(values, paramsEffect);
35151
+ if (!params) {
35152
+ return;
35153
+ }
35154
+ if (compareTwoJsValues(params, untracked(paramsEffect))) {
35155
+ return;
35156
+ }
35157
+ prerunAhead(action.bindParams(params));
35158
+ };
35159
+
35160
+ // The instance prerun is the one the arrival runs on — the one bound to the
35161
+ // params it reads (`true` for a param-less action) — so the arrival promotes
35162
+ // it to a run. A prefetch that fails is not reported: nothing on screen asked
35163
+ // for it, and the failure stays on the instance, where a run asks again — a
35164
+ // FAILED action is run, only RUNNING and COMPLETED are left alone — and the
35165
+ // arrival shows its own. It is left FAILED on purpose, not reset: a prerun is
35166
+ // often still in flight when the arrival promotes it (the press that brings
35167
+ // the arrival focuses the link, or is the one that asked), and a reset
35168
+ // landing after that would pull the answer from under the page reading it.
35169
+ const prerunAhead = (instance) => {
34862
35170
  let result;
34863
35171
  try {
34864
35172
  result = instance.prerun({ reason: "intent" });
@@ -35872,6 +36180,30 @@ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
35872
36180
  },
35873
36181
  };
35874
36182
  });
36183
+ /**
36184
+ * Tells the popup a source opens that a press on it has started, ahead of the
36185
+ * release that runs the command: what the opening will read can be asked for
36186
+ * now (see open_controller.js). A press rather than a hover, because what
36187
+ * that reads is keyed by the popup — one sheet per card — and a pointer
36188
+ * crossing a list of cards has chosen none of them; on a touch screen the
36189
+ * press is the first thing the finger says anyway.
36190
+ *
36191
+ * The target is found the way --navi-open finds it, but silently: nothing
36192
+ * runs here, and a source with no target says so at the release.
36193
+ */
36194
+ const announceOpeningPress = (source, command) => {
36195
+ if (command !== "--navi-open" && command !== "--navi-toggle") {
36196
+ return;
36197
+ }
36198
+ const commandFor = source.getAttribute("commandfor");
36199
+ const target = commandFor
36200
+ ? document.getElementById(commandFor)
36201
+ : resolveExplicitTarget(source) || resolveClosestExpandable(source);
36202
+ if (!target) {
36203
+ return;
36204
+ }
36205
+ dispatchCustomEvent(target, "navi_open_press");
36206
+ };
35875
36207
  // "--navi-close:all" closes every expandable above the source, nearest first —
35876
36208
  // a link leaving from a badge shown over a sheet leaves both. A surface that
35877
36209
  // refuses (a form asking about its changes) keeps what is above it open too:
@@ -37330,7 +37662,7 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
37330
37662
  return;
37331
37663
  }
37332
37664
  if (opened) {
37333
- signal.value = popupValue === undefined ? true : popupValue;
37665
+ signal.value = readOpenValue(popupValue);
37334
37666
  return;
37335
37667
  }
37336
37668
  const closedValue = readClosedValue(signalValue);
@@ -37362,6 +37694,9 @@ const readOpened = (signalValue, popupValue) => {
37362
37694
  }
37363
37695
  return signalValue === popupValue;
37364
37696
  };
37697
+ const readOpenValue = (popupValue) => {
37698
+ return popupValue === undefined ? true : popupValue;
37699
+ };
37365
37700
  const readClosedValue = (openSignalValue) => {
37366
37701
  return openSignalValue === true ? false : undefined;
37367
37702
  };
@@ -37448,6 +37783,29 @@ const useOpenPropsEffectOnOpenController = (
37448
37783
  }
37449
37784
  }
37450
37785
  : null;
37786
+ // A press that will open this popup has started (see announceOpeningPress
37787
+ // in commands.js). What the opening writes — the popup's value, in its
37788
+ // signal — is known before the release, so what the screen reads off it is
37789
+ // asked for now (see preloadState).
37790
+ useEffect(() => {
37791
+ if (!signal) {
37792
+ return undefined;
37793
+ }
37794
+ const element = openController.getElement?.();
37795
+ if (!element) {
37796
+ return undefined;
37797
+ }
37798
+ const onOpenPress = () => {
37799
+ if (openController.opened) {
37800
+ return;
37801
+ }
37802
+ preloadState(signal, readOpenValue(value));
37803
+ };
37804
+ element.addEventListener("navi_open_press", onOpenPress);
37805
+ return () => {
37806
+ element.removeEventListener("navi_open_press", onOpenPress);
37807
+ };
37808
+ }, [signal, value]);
37451
37809
  // Tracks whether the effect below has ever run before — only the very
37452
37810
  // first run gets the "mount already open" treatment (`open` truthy from
37453
37811
  // the start, or the uncontrolled, mount-only `defaultOpen`); every
@@ -38180,17 +38538,36 @@ const armOutsidePressClose = (
38180
38538
  * content ahead of the click that will open it. Deferring the build to the
38181
38539
  * opening puts its whole cost in the frame right after the click — the frame
38182
38540
  * where a delay is felt hardest — while the ~100-300ms between hovering a
38183
- * trigger and pressing it are free. The warming render is asynchronous
38184
- * (batched, not flushed): nothing here needs the content in the DOM before
38185
- * the click, only before the open that follows it.
38186
- *
38187
- * "while-opened" content is never warmed. That mode promises two things
38188
- * warming would break: the content is built at open time (so a `defaultValue`
38189
- * read at build time is fresh, not seeded at pointer-enter time), and it is
38190
- * only ever mounted between an open and a close — unmounting happens on close,
38191
- * so a warmed popup that never opens would keep its content in the document
38192
- * indefinitely. Callers lean on that guarantee (e.g. several pickers sharing
38193
- * one set of content ids because only one content exists at a time).
38541
+ * trigger and pressing it are free. The warming render starts at the frame
38542
+ * after the one that follows the intent: nothing here needs the content in the
38543
+ * DOM before the click, only before the open that follows it, and on a touch
38544
+ * screen `pointerenter` arrives together with the `pointerdown` — a build
38545
+ * started there would hold back the frame that shows the press. Two animation
38546
+ * frames, never a timeout: browsers hold timers back while a finger is down,
38547
+ * and a timeout started by the press fires at its release, on top of the click.
38548
+ *
38549
+ * "while-opened" content is warmed by an opening press, never by a hover or a
38550
+ * focus. That mode promises content built fresh for the gesture that opens
38551
+ * it, and mounted only while that gesture and its opening last: callers lean
38552
+ * on it (several pickers sharing one set of content ids, because only one
38553
+ * content exists at a time). A pointer crossing four triggers would build four
38554
+ * contents; a press is the start of one opening.
38555
+ *
38556
+ * Only what opens the popup can say its press is one, and it says so as the
38557
+ * press starts, with a `navi_open_press` on the popup (see
38558
+ * announceOpeningPress in commands.js): a `--navi-open` button, a picker's
38559
+ * trigger when a press is what opens it, an expandable's UI part. Not the
38560
+ * anchor: it says where the popup is placed, not what opens it — a card
38561
+ * opened by a hold is pressed all day by taps meant for what it holds.
38562
+ *
38563
+ * The press builds the content, and the press ending without an open throws
38564
+ * it away — its click reaching the document with the popup still closed, no
38565
+ * click coming after the release, the browser taking the gesture
38566
+ * (`pointercancel`), or the next press starting, before any of its own
38567
+ * handlers run: one press at a time holds a content. A keyboard opening builds
38568
+ * at open time. A popup with an `onOpen` is never warmed: that callback runs
38569
+ * before the content is built (see open_controller.js), and a content seeding
38570
+ * itself from what it writes must be built after it.
38194
38571
  */
38195
38572
 
38196
38573
 
@@ -38232,26 +38609,35 @@ const usePopupContentMount = (
38232
38609
  { mount = MOUNT_DEFAULT, anchor },
38233
38610
  ) => {
38234
38611
  const mountedAlways = mount === "always";
38235
- const [contentMounted, setContentMounted] = useState(
38612
+ const [contentMounted, setContentMountedState] = useState(
38236
38613
  () => mountedAlways || openController.opened,
38237
38614
  );
38238
- openController.mountContent = contentMounted
38239
- ? null
38240
- : () => {
38241
- const popupElement = ref?.current;
38242
- if (popupElement) {
38243
- popupsMountingContentForOpen.add(popupElement);
38244
- }
38245
- try {
38246
- flushSyncRendering(() => {
38247
- setContentMounted(true);
38248
- });
38249
- } finally {
38250
- if (popupElement) {
38251
- popupsMountingContentForOpen.delete(popupElement);
38252
- }
38253
- }
38254
- };
38615
+ // What was last asked for, ahead of the render that performs it: an open
38616
+ // landing in between (a press-warmed content given up just before the open
38617
+ // that wanted it) must still find it has to build.
38618
+ const contentMountedRef = useRef(contentMounted);
38619
+ const setContentMounted = (value) => {
38620
+ contentMountedRef.current = value;
38621
+ setContentMountedState(value);
38622
+ };
38623
+ openController.mountContent = () => {
38624
+ if (contentMountedRef.current) {
38625
+ return;
38626
+ }
38627
+ const popupElement = ref?.current;
38628
+ if (popupElement) {
38629
+ popupsMountingContentForOpen.add(popupElement);
38630
+ }
38631
+ try {
38632
+ flushSyncRendering(() => {
38633
+ setContentMounted(true);
38634
+ });
38635
+ } finally {
38636
+ if (popupElement) {
38637
+ popupsMountingContentForOpen.delete(popupElement);
38638
+ }
38639
+ }
38640
+ };
38255
38641
  openController.unmountContent =
38256
38642
  mount === "while-opened"
38257
38643
  ? () => {
@@ -38289,41 +38675,140 @@ const usePopupContentMount = (
38289
38675
  cancelIdle(idleId);
38290
38676
  };
38291
38677
  }, [mount, contentMounted]);
38292
- // Warm on intent (see the top comment; "while-opened" is excluded there).
38293
- // The anchor accepts the same shapes Popover resolves at open time — a
38294
- // string id, a ref, an element — but is resolved here at effect time: an id
38295
- // that matches nothing yet simply doesn't warm, the open still mounts the
38296
- // content like it always does.
38678
+ // Warm on intent (see the top comment).
38297
38679
  useEffect(() => {
38298
38680
  if (contentMounted || !anchor || mount === "while-opened") {
38299
38681
  return undefined;
38300
38682
  }
38301
- const anchorElement =
38302
- typeof anchor === "string"
38303
- ? document.getElementById(anchor)
38304
- : // A ref is unwrapped even when it holds nothing: an expandable with
38305
- // no UI part hands an empty ref over, and the ref object itself is
38306
- // truthy — it would reach addEventListener below and throw.
38307
- "current" in anchor
38308
- ? anchor.current
38309
- : anchor;
38683
+ const anchorElement = resolveAnchorElement(anchor);
38310
38684
  if (!anchorElement) {
38311
38685
  return undefined;
38312
38686
  }
38687
+ let cancelWarm = null;
38313
38688
  const warm = () => {
38314
- setContentMounted(true);
38689
+ if (cancelWarm) {
38690
+ return;
38691
+ }
38692
+ cancelWarm = requestFrameAfterNext(() => {
38693
+ setContentMounted(true);
38694
+ });
38315
38695
  };
38316
38696
  anchorElement.addEventListener("pointerenter", warm);
38317
38697
  anchorElement.addEventListener("focusin", warm);
38318
38698
  return () => {
38699
+ cancelWarm?.();
38319
38700
  anchorElement.removeEventListener("pointerenter", warm);
38320
38701
  anchorElement.removeEventListener("focusin", warm);
38321
38702
  };
38322
38703
  }, [contentMounted, anchor, mount]);
38704
+ // Warm on an opening press, for "while-opened" (see the top comment).
38705
+ // Listening for as long as the popup is there, not only while the content is
38706
+ // unmounted: the press that throws a warmed content away can be a new
38707
+ // opening press of this same popup, and it must warm again.
38708
+ useEffect(() => {
38709
+ if (mount !== "while-opened") {
38710
+ return undefined;
38711
+ }
38712
+ const element = openController.getElement?.();
38713
+ if (!element) {
38714
+ return undefined;
38715
+ }
38716
+ let stopPressWarm = null;
38717
+ const onOpeningPress = () => {
38718
+ if (contentMountedRef.current || openController.onOpen) {
38719
+ return;
38720
+ }
38721
+ const cancelBuild = requestFrameAfterNext(() => {
38722
+ setContentMounted(true);
38723
+ });
38724
+ const stopWatching = watchPressEnd(() => {
38725
+ stopPressWarm = null;
38726
+ cancelBuild();
38727
+ if (!openController.opened) {
38728
+ setContentMounted(false);
38729
+ }
38730
+ });
38731
+ stopPressWarm = () => {
38732
+ cancelBuild();
38733
+ stopWatching();
38734
+ };
38735
+ };
38736
+ element.addEventListener("navi_open_press", onOpeningPress);
38737
+ return () => {
38738
+ stopPressWarm?.();
38739
+ element.removeEventListener("navi_open_press", onOpeningPress);
38740
+ };
38741
+ }, [mount]);
38323
38742
 
38324
38743
  return contentMounted;
38325
38744
  };
38326
38745
 
38746
+ // The anchor accepts the same shapes Popover resolves at open time — a string
38747
+ // id, a ref, an element — but is resolved at effect time: an id that matches
38748
+ // nothing yet simply doesn't warm, the open still mounts the content.
38749
+ const resolveAnchorElement = (anchor) => {
38750
+ if (typeof anchor === "string") {
38751
+ return document.getElementById(anchor);
38752
+ }
38753
+ // A ref is unwrapped even when it holds nothing: an expandable with no UI
38754
+ // part hands an empty ref over, and the ref object itself is truthy — it
38755
+ // would reach addEventListener and throw.
38756
+ if ("current" in anchor) {
38757
+ return anchor.current;
38758
+ }
38759
+ return anchor;
38760
+ };
38761
+
38762
+ // The next frame paints what the input just changed (the pressed trigger); the
38763
+ // callback runs at the start of the one after.
38764
+ const requestFrameAfterNext = (callback) => {
38765
+ let frame = requestAnimationFrame(() => {
38766
+ frame = requestAnimationFrame(callback);
38767
+ });
38768
+ return () => {
38769
+ cancelAnimationFrame(frame);
38770
+ };
38771
+ };
38772
+
38773
+ // How long the click of a released press may take to arrive. Nothing tells a
38774
+ // page that a release brings no click — a press that became a drag has its
38775
+ // click swallowed (suppressClickAfterGesture in @jsenv/dom) — so past this the
38776
+ // press is taken to have ended without one. Longer than the 300ms a browser
38777
+ // still waits before the click of a tap on a page not sized for phones.
38778
+ const PRESS_CLICK_WAIT_MS = 500;
38779
+
38780
+ // Installed during the press's own pointerdown: the capture listeners below
38781
+ // only hear the presses after it.
38782
+ const watchPressEnd = (onEnd) => {
38783
+ let timeout;
38784
+ const stop = () => {
38785
+ clearTimeout(timeout);
38786
+ document.removeEventListener("click", onClick);
38787
+ document.removeEventListener("pointerup", onPointerUp, { capture: true });
38788
+ document.removeEventListener("pointercancel", end, { capture: true });
38789
+ document.removeEventListener("pointerdown", end, { capture: true });
38790
+ };
38791
+ const end = () => {
38792
+ stop();
38793
+ onEnd();
38794
+ };
38795
+ // Bubble phase, on the document: every click handler that could open the
38796
+ // popup has run by then.
38797
+ const onClick = (clickEvent) => {
38798
+ if (isPressDrivenClick(clickEvent)) {
38799
+ end();
38800
+ }
38801
+ };
38802
+ const onPointerUp = () => {
38803
+ timeout = setTimeout(end, PRESS_CLICK_WAIT_MS);
38804
+ };
38805
+ document.addEventListener("click", onClick);
38806
+ document.addEventListener("pointerup", onPointerUp, { capture: true });
38807
+ document.addEventListener("pointercancel", end, { capture: true });
38808
+ document.addEventListener("pointerdown", end, { capture: true });
38809
+ return stop;
38810
+ };
38811
+
38327
38812
  /**
38328
38813
  * A variant of useLayoutEffect that accounts for ancestor <dialog>/<details>
38329
38814
  * or popover visibility.
@@ -39381,6 +39866,9 @@ const useUIStateController = (
39381
39866
  // The suggestion this control started on — what tells a field showing
39382
39867
  // its default from one carrying an answer (see isUIStateHeld).
39383
39868
  defaultValue: controlInfo.defaultValue,
39869
+ // The default last put on screen, which lags `defaultValue` while an
39870
+ // edit holds a newer one back (see followDefaultValue).
39871
+ defaultValueTaken: controlInfo.defaultValue,
39384
39872
 
39385
39873
  facadeChild: null,
39386
39874
  // Set for the duration of one interaction by whatever wants the
@@ -39490,6 +39978,18 @@ const useUIStateController = (
39490
39978
  e.currentTarget || controller.ref.current,
39491
39979
  );
39492
39980
  const currentUIState = controller.uiState;
39981
+ // What a control is STARTED on is accepted, as its own defaultValue
39982
+ // would be: a value placed on it as it registers, or what a picker
39983
+ // takes up from its popup while it holds nothing of its own. A
39984
+ // rollback goes back there, and a control left on it holds no edit.
39985
+ if (!controller.hasStateProp) {
39986
+ if (
39987
+ e.type === "initial_state_push" ||
39988
+ (e.type === "facade_child_mount_sync" && !controller.holdsEdit())
39989
+ ) {
39990
+ controller.state = newUIState;
39991
+ }
39992
+ }
39493
39993
  const stateIsTheSame = compareTwoJsValues(newUIState, currentUIState);
39494
39994
  if (stateIsTheSame) {
39495
39995
  if (controlType === "button" || controlType === "link") {
@@ -39655,7 +40155,12 @@ const useUIStateController = (
39655
40155
  }
39656
40156
  // initial_state_push is pure initialization (equivalent to defaultValue on the
39657
40157
  // child itself): skip uiAction entirely so no side effects fire on mount.
39658
- if (e.type !== "initial_state_push") {
40158
+ // A value the outside moved since (see outside_value_follow) is the
40159
+ // same initialization, later.
40160
+ if (
40161
+ e.type !== "initial_state_push" &&
40162
+ e.type !== "outside_value_follow"
40163
+ ) {
39659
40164
  // Still fire uiAction so external listeners (e.g. signals) stay in
39660
40165
  // sync, but do NOT fire the command and do NOT notify the parent —
39661
40166
  // both would cause an infinite loop when a parent cascades state
@@ -39709,6 +40214,19 @@ const useUIStateController = (
39709
40214
  stateChanged: true,
39710
40215
  });
39711
40216
  }
40217
+ if (
40218
+ e.type === "outside_value_follow" ||
40219
+ (e.type === "facade_child_mount_sync" && isOutsideValueFollow(e))
40220
+ ) {
40221
+ // Heard the way a control mounting on that value would be:
40222
+ // silently, nobody acted. But heard, all the way up — a picker
40223
+ // whose popup followed tells its own group in turn — or what
40224
+ // the groups send is not what is on screen.
40225
+ s.parentUIStateController?.onChildUIAction(controller, e, {
40226
+ stateChanged: true,
40227
+ silent: true,
40228
+ });
40229
+ }
39712
40230
  return true;
39713
40231
  }
39714
40232
  s.parentUIStateController?.onChildUIAction(controller, e, {
@@ -39816,6 +40334,41 @@ const useUIStateController = (
39816
40334
  // pick — the signal would hand it straight back on the next render,
39817
40335
  // and the refused value would win over the rollback.
39818
40336
  writeBoundSignal(controller.state);
40337
+ controller.followDefaultValue();
40338
+ },
40339
+ // Something typed, picked or toggled since the value the outside last
40340
+ // gave or accepted.
40341
+ holdsEdit: () => {
40342
+ return !compareTwoJsValues(controller.uiState, controller.state);
40343
+ },
40344
+ // An uncontrolled control starts on its defaultValue, and a new one is
40345
+ // what it would start on if it were mounted again — a record saved
40346
+ // and handed back normalized, refreshed from elsewhere. It takes it
40347
+ // while it holds no edit. An edit is never undone; the default waits
40348
+ // for it to be accepted or rolled back.
40349
+ followDefaultValue: () => {
40350
+ // A bound signal carries its own default, followed through
40351
+ // stateFromSignal; a proxy shows the control it stands for.
40352
+ if (
40353
+ controller.hasStateProp ||
40354
+ controller.isProxy ||
40355
+ controller.props.signal
40356
+ ) {
40357
+ return;
40358
+ }
40359
+ const { defaultValue } = controller;
40360
+ if (compareTwoJsValues(defaultValue, controller.defaultValueTaken)) {
40361
+ return;
40362
+ }
40363
+ if (controller.holdsEdit()) {
40364
+ return;
40365
+ }
40366
+ controller.defaultValueTaken = defaultValue;
40367
+ controller.state = defaultValue;
40368
+ controller.setUIState(
40369
+ defaultValue,
40370
+ new CustomEvent("outside_value_follow", { detail: {} }),
40371
+ );
39819
40372
  },
39820
40373
  // Read by the callout manager when it has nowhere else to point.
39821
40374
  getCalloutAnchorElement: (event) =>
@@ -39829,7 +40382,12 @@ const useUIStateController = (
39829
40382
  if (controller.hasStateProp || controller.queuedActionAllowedEvent) {
39830
40383
  return;
39831
40384
  }
40385
+ // A picker told yes is told for what its popup shows too, the way a
40386
+ // group tells its children. First, so that a default one of them
40387
+ // takes on the way is part of what this control accepts below.
40388
+ controller.facadeChild?.acknowledgeUIState();
39832
40389
  controller.state = controller.uiState;
40390
+ controller.followDefaultValue();
39833
40391
  },
39834
40392
  onActionEnd: (e) => {
39835
40393
  debugUIState(`"${controlType}" actionEnd called`);
@@ -39986,6 +40544,7 @@ const useUIStateController = (
39986
40544
  }
39987
40545
  }
39988
40546
  }
40547
+ controller.followDefaultValue();
39989
40548
  }
39990
40549
  return liveValues();
39991
40550
  },
@@ -40576,7 +41135,12 @@ const useUIGroupStateController = (
40576
41135
  // deferred alongside a mount sync stays a real change.
40577
41136
  const pendingChange = pendingChangeRef.current;
40578
41137
  pendingChangeRef.current = {
40579
- e,
41138
+ // A default followed is not replaced by a mount deferred beside
41139
+ // it: the event is what lets the sync below read the children.
41140
+ e:
41141
+ pendingChange && isOutsideValueFollow(pendingChange.e)
41142
+ ? pendingChange.e
41143
+ : e,
40580
41144
  notifyExternal:
40581
41145
  pendingChange?.notifyExternal === true ? true : notifyExternal,
40582
41146
  actingChild,
@@ -40584,6 +41148,13 @@ const useUIGroupStateController = (
40584
41148
  return;
40585
41149
  }
40586
41150
  const { controller } = s;
41151
+ if (isOutsideValueFollow(e)) {
41152
+ // Not a partial reading: the child was there already and its value
41153
+ // moved, told by the outside. The group is worth what its children
41154
+ // show, whatever it was handed before — kept, the value it sends
41155
+ // would be one the screen no longer shows.
41156
+ controller.stateGivenFromAbove = false;
41157
+ }
40587
41158
  // A child mounting or unmounting is not somebody answering: while the
40588
41159
  // children of a group are still arriving, their aggregate is a partial
40589
41160
  // reading, and taking it for the truth is how the value the group was
@@ -40762,6 +41333,9 @@ const useUIGroupStateController = (
40762
41333
  name,
40763
41334
  value,
40764
41335
  defaultValue,
41336
+ // Same as the leaf's (see useUIStateController): the default last
41337
+ // placed on the children, behind `defaultValue` while an edit waits.
41338
+ defaultValueTaken: defaultValue,
40765
41339
  hasValueProp,
40766
41340
  hasDefaultValueProp,
40767
41341
  props,
@@ -41045,6 +41619,7 @@ const useUIGroupStateController = (
41045
41619
  }
41046
41620
  }
41047
41621
  onChange(e, { notifyExternal: true });
41622
+ controller.followDefaultValue();
41048
41623
  },
41049
41624
  clearUIState: (e) => {
41050
41625
  const ev = new CustomEvent("propagate_down_clear_ui_state", {
@@ -41073,6 +41648,46 @@ const useUIGroupStateController = (
41073
41648
  c.acknowledgeUIState();
41074
41649
  }
41075
41650
  }
41651
+ controller.followDefaultValue();
41652
+ },
41653
+ // A group holds no value of its own to have edited: it holds an edit
41654
+ // when one of its children does.
41655
+ holdsEdit: () => {
41656
+ return childUIStateControllerArray.some(
41657
+ (c) => shouldPropagateStateToChild(c) && c.holdsEdit(),
41658
+ );
41659
+ },
41660
+ // The leaf's rule (see useUIStateController), for a group given a
41661
+ // plain `defaultValue`: a new one is placed on the children the way
41662
+ // the first one was as they registered, while none of them holds an
41663
+ // edit. A signal's own default is followed by the update below.
41664
+ followDefaultValue: () => {
41665
+ if (controller.hasValueProp || controller.props.signal) {
41666
+ return;
41667
+ }
41668
+ const { defaultValue } = controller;
41669
+ if (compareTwoJsValues(defaultValue, controller.defaultValueTaken)) {
41670
+ return;
41671
+ }
41672
+ if (controller.holdsEdit()) {
41673
+ return;
41674
+ }
41675
+ controller.defaultValueTaken = defaultValue;
41676
+ const followEvent = new CustomEvent("outside_value_follow", {
41677
+ detail: {},
41678
+ });
41679
+ const placeEvent = new CustomEvent("initial_state_push", {
41680
+ detail: {},
41681
+ });
41682
+ chainEvent(placeEvent, followEvent);
41683
+ controller.setUIState(
41684
+ defaultValue === undefined ? fallbackState : defaultValue,
41685
+ placeEvent,
41686
+ );
41687
+ s.parentUIStateController?.onChildUIAction(controller, followEvent, {
41688
+ stateChanged: true,
41689
+ silent: true,
41690
+ });
41076
41691
  },
41077
41692
  onActionEnd: (e) => {
41078
41693
  acknowledgeOwnAction(controller);
@@ -41195,6 +41810,7 @@ const useUIGroupStateController = (
41195
41810
  // former value back over it on the next child interaction.
41196
41811
  placeChildrenFrom(defaultValue);
41197
41812
  }
41813
+ controller.followDefaultValue();
41198
41814
 
41199
41815
  return liveValues();
41200
41816
  },
@@ -41421,7 +42037,11 @@ const useUIFacadeStateController = (props, realUIStateController) => {
41421
42037
  if (child !== firstChildControllerRef.current) {
41422
42038
  return;
41423
42039
  }
41424
- if (silent && uiStateHoldsNothing(child.uiState)) {
42040
+ if (
42041
+ silent &&
42042
+ !isOutsideValueFollow(e) &&
42043
+ uiStateHoldsNothing(child.uiState)
42044
+ ) {
41425
42045
  // A silent sync means the child's own structure changed (children
41426
42046
  // mounted/unmounted), not that the user acted. A child that ends up
41427
42047
  // with no value there is one that currently *cannot* express one —
@@ -41432,7 +42052,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
41432
42052
  // still nothing to adopt: the sync would only respell one nothing
41433
42053
  // as another (an array picker's [] becoming undefined, say) and
41434
42054
  // hand that to uiAction — an empty array picker opened its popup
41435
- // and told its owner the value changed.
42055
+ // and told its owner the value changed. A default followed down to
42056
+ // nothing is not that: the outside emptied a value the child held.
41436
42057
  return;
41437
42058
  }
41438
42059
  updatingRef.current = true;
@@ -41496,6 +42117,69 @@ const useUIFacadeStateController = (props, realUIStateController) => {
41496
42117
  return scope.controller;
41497
42118
  };
41498
42119
 
42120
+ /**
42121
+ * What every control of a picker — the picker and the controls in its popup —
42122
+ * has been accepted on as the popup opens. Read back by followOutsideSinceOpen
42123
+ * when the popup is cancelled.
42124
+ */
42125
+ const readStatesAtOpen = (pickerController) => {
42126
+ const statesAtOpen = new Map();
42127
+ visitControllers(pickerController, (controller) => {
42128
+ if (Object.hasOwn(controller, "state")) {
42129
+ statesAtOpen.set(controller, controller.state);
42130
+ }
42131
+ });
42132
+ return statesAtOpen;
42133
+ };
42134
+
42135
+ /**
42136
+ * A cancel puts back the value the picker held at open, which takes back
42137
+ * everything that moved since — and not only what the user moved: a record
42138
+ * refreshed while the popup was open, a default that arrived under a field
42139
+ * holding an edit. Those were not the user's to take back. Once the value at
42140
+ * open is back, each control the outside moved since shows what the outside
42141
+ * holds again, and a default left waiting on an edit is taken there.
42142
+ *
42143
+ * Children before the control around them: a picker given its value by its
42144
+ * owner has the last word over the controls in its popup.
42145
+ */
42146
+ const followOutsideSinceOpen = (pickerController, statesAtOpen) => {
42147
+ visitControllers(
42148
+ pickerController,
42149
+ (controller) => {
42150
+ if (
42151
+ statesAtOpen.has(controller) &&
42152
+ !compareTwoJsValues(controller.state, statesAtOpen.get(controller)) &&
42153
+ controller.holdsEdit()
42154
+ ) {
42155
+ controller.setUIState(
42156
+ controller.state,
42157
+ new CustomEvent("outside_value_follow", { detail: {} }),
42158
+ );
42159
+ }
42160
+ controller.followDefaultValue();
42161
+ },
42162
+ { childrenFirst: true },
42163
+ );
42164
+ };
42165
+
42166
+ // A picker reaches the controls of its popup through its facade child, a group
42167
+ // through the children it registered.
42168
+ const visitControllers = (controller, visit, { childrenFirst } = {}) => {
42169
+ if (!childrenFirst) {
42170
+ visit(controller);
42171
+ }
42172
+ const children = controller.facadeChild
42173
+ ? [controller.facadeChild]
42174
+ : controller.getChildControllers?.() || [];
42175
+ for (const child of children) {
42176
+ visitControllers(child, visit, { childrenFirst });
42177
+ }
42178
+ if (childrenFirst) {
42179
+ visit(controller);
42180
+ }
42181
+ };
42182
+
41499
42183
  const describePicker = (props) =>
41500
42184
  `<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
41501
42185
 
@@ -41584,10 +42268,18 @@ const INTERNAL_EVENT_SET = new Set([
41584
42268
  // no action of the control's own — but what it holds really did move, so
41585
42269
  // uiAction, the bound signal and the parent notification below all happen.
41586
42270
  "auto_fix",
42271
+ // A control put on a value the outside moved while nobody was editing it: a
42272
+ // new defaultValue (see followDefaultValue), or what a cancel puts back
42273
+ // (see followOutsideSinceOpen). Initialization arriving after mount, so
42274
+ // nothing fires, and the groups above hear it silently.
42275
+ "outside_value_follow",
41587
42276
  ]);
41588
42277
  const isInternalEvent = (e) => {
41589
42278
  return INTERNAL_EVENT_SET.has(e.type);
41590
42279
  };
42280
+ const isOutsideValueFollow = (e) => {
42281
+ return Boolean(findEvent(e, "outside_value_follow"));
42282
+ };
41591
42283
 
41592
42284
  /**
41593
42285
  * A value handed DOWN to a control by whoever owns it: a picker filling its
@@ -47106,6 +47798,35 @@ const usePreloadOnIntent = (ref, href, prefetch = true) => {
47106
47798
  }, [ref, href, prefetch]);
47107
47799
  };
47108
47800
 
47801
+ /**
47802
+ * What a popup's opening will read, asked for when the press on the element
47803
+ * whose command opens it starts (see announceOpeningPress): the popup, the
47804
+ * value it opens on and so the address it will write are all known before
47805
+ * the release.
47806
+ */
47807
+ const usePreloadOpeningOnPress = (ref, command, prefetch = true) => {
47808
+ useEffect(() => {
47809
+ if (!prefetch || !command) {
47810
+ return undefined;
47811
+ }
47812
+ const element = ref.current;
47813
+ if (!element) {
47814
+ return undefined;
47815
+ }
47816
+ const onPointerDown = (pointerdownEvent) => {
47817
+ // A right click opens a menu, never the popup.
47818
+ if (pointerdownEvent.button !== 0) {
47819
+ return;
47820
+ }
47821
+ announceOpeningPress(element, command);
47822
+ };
47823
+ element.addEventListener("pointerdown", onPointerDown);
47824
+ return () => {
47825
+ element.removeEventListener("pointerdown", onPointerDown);
47826
+ };
47827
+ }, [ref, command, prefetch]);
47828
+ };
47829
+
47109
47830
  /**
47110
47831
  * Hook that reactively checks if a URL is visited.
47111
47832
  * Re-renders when the visited URL set changes.
@@ -51115,7 +51836,8 @@ const useExpandableContext = partName => {
51115
51836
  * content — and rebuilds it from scratch on every expansion. Under the
51116
51837
  * other three, intent on the UI part (pointer entering it, focus landing in
51117
51838
  * it) builds the content ahead of the click; `"while-opened"` content is
51118
- * never warmed, it is built at the open.
51839
+ * built by a press on it only, and thrown away if that press does not
51840
+ * expand it.
51119
51841
  */
51120
51842
  const Expandable = props => {
51121
51843
  import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -51194,8 +51916,14 @@ const Expandable = props => {
51194
51916
  // before the click ever fires — so what held the focus has to be remembered
51195
51917
  // at pointerdown time.
51196
51918
  const focusedAtPointerDownRef = useRef(null);
51197
- const onUIPointerDown = () => {
51919
+ const onUIPointerDown = pointerdownEvent => {
51198
51920
  focusedAtPointerDownRef.current = document.activeElement;
51921
+ // The start of an expansion, announced as a --navi-open button announces
51922
+ // its own (see announceOpeningPress in commands.js) — unless aimed at a
51923
+ // control inside the UI part, whose click does not toggle (see onUIClick).
51924
+ if (pointerdownEvent.button === 0 && !openController.opened && !isAimedAtControlInsideUI(pointerdownEvent.target)) {
51925
+ dispatchCustomEvent(rootRef.current, "navi_open_press");
51926
+ }
51199
51927
  };
51200
51928
 
51201
51929
  // The content keeps its final size while the track animates (see the top
@@ -51493,17 +52221,18 @@ const Expandable = props => {
51493
52221
  if (clickEvent.defaultPrevented) {
51494
52222
  return;
51495
52223
  }
51496
- const {
51497
- target
51498
- } = clickEvent;
51499
- if (target.nodeType === 1) {
51500
- const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
51501
- if (interactiveElement && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement)) {
51502
- return;
51503
- }
52224
+ if (isAimedAtControlInsideUI(clickEvent.target)) {
52225
+ return;
51504
52226
  }
51505
52227
  toggle(clickEvent);
51506
52228
  };
52229
+ const isAimedAtControlInsideUI = target => {
52230
+ if (target.nodeType !== 1) {
52231
+ return false;
52232
+ }
52233
+ const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
52234
+ return Boolean(interactiveElement) && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement);
52235
+ };
51507
52236
 
51508
52237
  // Space/Enter on the UI part itself (role button) — a key pressed on a
51509
52238
  // control inside it belongs to that control.
@@ -52427,6 +53156,9 @@ const ButtonUI = props => {
52427
53156
  // Nothing to prefetch on the way to another document: the routes do not
52428
53157
  // lead there.
52429
53158
  usePreloadOnIntent(ref, href, isDocument ? false : prefetch);
53159
+ // A press the button refuses opens nothing: nothing to ask ahead for.
53160
+ const refusesPress = basePseudoState[":read-only"] || basePseudoState[":disabled"] || loading;
53161
+ usePreloadOpeningOnPress(ref, props.command, prefetch && !refusesPress);
52430
53162
  useAccentColorAttributes(ref, null, {
52431
53163
  elementSelector: visualSelector
52432
53164
  });
@@ -52921,8 +53653,11 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
52921
53653
  * pointer or the focus arrives, ahead of the press (see
52922
53654
  * docs/dynamic_import.md): the route actions that ask nothing of the
52923
53655
  * address — a page's code, a read without params — are prerun; one keyed by
52924
- * a param waits for the arrival. `false` for a destination not worth
52925
- * fetching on a hover.
53656
+ * a param waits for the arrival. With a `--navi-open`/`--navi-toggle`
53657
+ * command instead, the press — not the hover: what it asks is keyed by the
53658
+ * popup — preruns the route actions whose params the popup's opening
53659
+ * changes, read as if its `signal` held its `value`. `false` for a
53660
+ * destination not worth fetching ahead.
52926
53661
  * @param {boolean} [pressableDuringRouteTransition] Keep answering presses
52927
53662
  * while a route transition plays: what a movement photographs goes deaf to
52928
53663
  * the pointer for its whole length, and the door that opened the page — a
@@ -59835,6 +60570,52 @@ const SlideContainer = ({
59835
60570
  drag.areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
59836
60571
  paintDrag();
59837
60572
  },
60573
+ // The slide being brought in walked whole and the hand still going, or a
60574
+ // travel caught on its way thrown on: either way the hand asks for the
60575
+ // slide past it, and the gesture moves on to it. Nothing on screen moves
60576
+ // for that — the slide reached becomes the one dragged, the one the hand
60577
+ // came from stays next to it for a hand that turns around, and the slide
60578
+ // past it is put on the other side.
60579
+ onEdge: ({
60580
+ axis,
60581
+ sign
60582
+ }) => {
60583
+ const reached = sign > 0 ? drag.areaBack : drag.areaOn;
60584
+ const {
60585
+ slideElements,
60586
+ placeOf
60587
+ } = readMap();
60588
+ const reachedElement = reached && slideElements.find(slideElement => readArea(slideElement) === reached);
60589
+ if (!reachedElement) {
60590
+ return false;
60591
+ }
60592
+ // The gate goToArea reads at the release, asked of the slide the hand
60593
+ // is now in: a slide holding on to the user is not walked through.
60594
+ const holds = reachedElement.hasAttribute(sign > 0 ? "data-prevent-nav-previous" : "data-prevent-nav-next");
60595
+ const past = holds ? undefined : axis === "x" ? areaTowards(-sign, 0, reached) : areaTowards(0, -sign, reached);
60596
+ if (!past) {
60597
+ return false;
60598
+ }
60599
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
60600
+ const basePlace = stageRef.current?.placeByArea.get(reached) || placeOf.get(reached) || {
60601
+ x: 0,
60602
+ y: 0
60603
+ };
60604
+ drag.areaBack = sign > 0 ? past : drag.area;
60605
+ drag.areaOn = sign > 0 ? drag.area : past;
60606
+ drag.area = reached;
60607
+ drag.basePlace = basePlace;
60608
+ drag.baseOffset = {
60609
+ x: -basePlace.x * drag.box.width,
60610
+ y: -basePlace.y * drag.box.height
60611
+ };
60612
+ stageDrag(drag, readArea(currentElement));
60613
+ return {
60614
+ size: axis === "x" ? drag.box.width : drag.box.height,
60615
+ travelBack: Boolean(drag.areaBack),
60616
+ travelOn: Boolean(drag.areaOn)
60617
+ };
60618
+ },
59838
60619
  onEnd: ({
59839
60620
  axis,
59840
60621
  sign,
@@ -63805,9 +64586,11 @@ const css$E = /* css */`
63805
64586
  * the cost on the critical render. `"while-opened"` throws them away once
63806
64587
  * the popup has finished closing, for content whose fresh state is its
63807
64588
  * initial state: an uncontrolled field seeded from a `defaultValue` that
63808
- * changed while the popup was closed. Whatever the value, intent on the
63809
- * anchor (pointer entering it, focus landing in it) builds the content
63810
- * ahead of the click.
64589
+ * changed while the popup was closed. Intent on the anchor (pointer
64590
+ * entering it, focus landing in it) builds the content ahead of the click;
64591
+ * under `"while-opened"`, only a press on what opens the popup does (a
64592
+ * `--navi-open` button, a picker's trigger), never with an `onOpen`, and a
64593
+ * press that does not open the popup throws it away.
63811
64594
  * @param {import("ignore:preact").ComponentChildren} props.children
63812
64595
  */
63813
64596
  const Dialog = props => {
@@ -65567,9 +66350,11 @@ const css$D = /* css */`
65567
66350
  * the cost on the critical render. `"while-opened"` throws them away once
65568
66351
  * the popup has finished closing, for content whose fresh state is its
65569
66352
  * initial state: an uncontrolled field seeded from a `defaultValue` that
65570
- * changed while the popup was closed. Whatever the value, intent on the
65571
- * anchor (pointer entering it, focus landing in it) builds the content
65572
- * ahead of the click.
66353
+ * changed while the popup was closed. Intent on the anchor (pointer
66354
+ * entering it, focus landing in it) builds the content ahead of the click;
66355
+ * under `"while-opened"`, only a press on what opens the popup does (a
66356
+ * `--navi-open` button, a picker's trigger), never with an `onOpen`, and a
66357
+ * press that does not open the popup throws it away.
65573
66358
  * @param {import("ignore:preact").ComponentChildren} props.children
65574
66359
  */
65575
66360
  const Popover = props => {
@@ -67242,7 +68027,10 @@ const PickerCustom = props => {
67242
68027
  // a defaultValue holds nothing, so closing on it untouched IS the answer
67243
68028
  // ("yes, 2h15") — the same rule Form applies to an untouched field (see
67244
68029
  // isUIStateHeld). Read at open, before anything inside can change it.
67245
- const heldAtOpen = isUIStateHeld(getPickerInput(ref.current)?.__uiStateController__);
68030
+ const pickerController = getPickerInput(ref.current)?.__uiStateController__;
68031
+ const heldAtOpen = isUIStateHeld(pickerController);
68032
+ // What a cancel must not take back: see followOutsideSinceOpen.
68033
+ const statesAtOpen = pickerController ? readStatesAtOpen(pickerController) : null;
67246
68034
  debugPopup(openEvent, `picker opened, store value at open`, valueAtOpen, heldAtOpen ? `(held)` : `(a suggestion, not an answer yet)`);
67247
68035
  onOpen?.(openEvent);
67248
68036
  return {
@@ -67311,6 +68099,9 @@ const PickerCustom = props => {
67311
68099
  });
67312
68100
  chainEvent(rollbackEvent, closeEvent);
67313
68101
  inputEl.__uiStateController__.setUIState(valueAtOpen, rollbackEvent);
68102
+ if (statesAtOpen) {
68103
+ followOutsideSinceOpen(inputEl.__uiStateController__, statesAtOpen);
68104
+ }
67314
68105
  } else if (!heldAtOpen) {
67315
68106
  // Confirmed a suggestion: nothing changed, so nothing has told the
67316
68107
  // control's own bound signal / uiAction that this is now the
@@ -67402,6 +68193,11 @@ const PickerCustom = props => {
67402
68193
  "onnavi_request_open": e => {
67403
68194
  dispatchCustomEvent(popupRef.current, "navi_request_open", e.detail);
67404
68195
  },
68196
+ // And the press announcing that request (see announceOpeningPress in
68197
+ // commands.js), for the popup's content to be built on it.
68198
+ "onnavi_open_press": () => {
68199
+ dispatchCustomEvent(popupRef.current, "navi_open_press");
68200
+ },
67405
68201
  "onnavi_request_close": e => {
67406
68202
  const closing = dispatchCustomEvent(popupRef.current, "navi_request_close", e.detail);
67407
68203
  if (!closing) {
@@ -67596,6 +68392,25 @@ const PickerCustom = props => {
67596
68392
  // below), and the keyboard keeps its own ways in (the shortcuts above).
67597
68393
  const openOnList = Array.isArray(openOn) ? openOn : [openOn];
67598
68394
  const opensOnPress = openOnList.includes("press");
68395
+ // A press on the trigger of a picker a press opens is announced as it
68396
+ // starts, as a --navi-open button announces its own (see
68397
+ // announceOpeningPress in commands.js). Only a press that can open it: a
68398
+ // picker opened by a hold says nothing on a tap, nor does one refusing
68399
+ // to open (read-only with openWhileReadOnly={false}, busy). The
68400
+ // interactivity is asked alone, not the whole gate: a drag source
68401
+ // cancels the pointerdown, and the click that opens still comes.
68402
+ if (opensOnPress) {
68403
+ pickerProps.onPointerDown = pointerdownEvent => {
68404
+ props.onPointerDown?.(pointerdownEvent);
68405
+ const pickerEl = ref.current;
68406
+ if (pointerdownEvent.button !== 0 || openController.opened || isWithinPickerContent(pointerdownEvent.target) || !allowsInteraction(pickerEl, {
68407
+ intent: "read"
68408
+ })) {
68409
+ return;
68410
+ }
68411
+ dispatchCustomEvent(pickerEl, "navi_open_press");
68412
+ };
68413
+ }
67599
68414
  let interactions = props.interactions;
67600
68415
  if (!opensOnPress) {
67601
68416
  interactions = {
@@ -80475,6 +81290,9 @@ const css$n = /* css */`.navi_split_button {
80475
81290
  * defaultValue?: any,
80476
81291
  * label?: import("ignore:preact").ComponentChildren,
80477
81292
  * action?: (value: any, event: Event) => void | Promise<void>,
81293
+ * command?: string,
81294
+ * commandFor?: string,
81295
+ * prefetch?: boolean,
80478
81296
  * onValueChange?: (value: any, event: Event) => void,
80479
81297
  * chooseEffect?: "select" | "run",
80480
81298
  * menuLabel?: string,
@@ -80528,6 +81346,12 @@ const css$n = /* css */`.navi_split_button {
80528
81346
  * entry given. Pressed on the button it is the entry the button stands for;
80529
81347
  * with `chooseEffect="run"` it is also the entry just chosen. Awaited: the
80530
81348
  * whole split button is busy until it settles.
81349
+ * @param {string} [command] What a press on the button does once its `action`
81350
+ * is done, as on a `<Button>` (with `commandFor`, and `command-value` when
81351
+ * what the command is about is not the entry the button stands for). A
81352
+ * split button whose button opens a popup says it here rather than from its
81353
+ * `action`: the press is then read as an opening before it runs, and asks
81354
+ * ahead for what the popup's opening reads (see `prefetch` on Button).
80531
81355
  * @param {"select"|"run"} [chooseEffect="select"] What choosing an entry does.
80532
81356
  * "select" hands the button that entry, to be run by a press on it — for
80533
81357
  * something one does again and again, where the last choice is the likely
@@ -80569,6 +81393,10 @@ const SplitButton = props => {
80569
81393
  defaultValue,
80570
81394
  label,
80571
81395
  action,
81396
+ command,
81397
+ commandFor,
81398
+ "command-value": commandValue,
81399
+ prefetch,
80572
81400
  onValueChange,
80573
81401
  chooseEffect = "select",
80574
81402
  menuLabel = naviI18n("button.more_actions"),
@@ -80662,6 +81490,10 @@ const SplitButton = props => {
80662
81490
  id: idResolved,
80663
81491
  value: valueResolved,
80664
81492
  action: actionResolved,
81493
+ command: command,
81494
+ commandFor: commandFor,
81495
+ "command-value": commandValue,
81496
+ prefetch: prefetch,
80665
81497
  ...halfProps,
80666
81498
  children: label === undefined ? optionShown?.label : label
80667
81499
  }), jsxs("div", {