@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, 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, 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, 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, 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, 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;
@@ -298,6 +321,112 @@ const urlToScheme = (url) => {
298
321
  return scheme;
299
322
  };
300
323
 
324
+ /**
325
+ * The document's rendering, held for the one frame a view transition needs.
326
+ *
327
+ * The browser does not take the picture of the page being left when a
328
+ * transition is ASKED for — it takes it at the next frame, just before running
329
+ * the update callback. Preact renders sooner than that, in a microtask: so a
330
+ * change nobody asked for (a tab pressed, the back button) has already reached
331
+ * the DOM when the picture is taken, and the picture is of the page ARRIVING.
332
+ * Both sides of the animation then show it, and one watches a page slide onto
333
+ * itself.
334
+ *
335
+ * So what Preact has queued waits until the update callback, which is the
336
+ * moment the API is built around — the change belongs inside it. The whole
337
+ * document is held: it is about to be frozen under a picture anyway.
338
+ *
339
+ * ONE hold for the whole document, whoever animates. The hold is a wrapper
340
+ * around Preact's `options.debounceRendering`, and two of them installed
341
+ * independently restore each other in the wrong order when they let go — every
342
+ * render queued in between is then handed to a wrapper nobody will ever
343
+ * release. Everything that photographs a navigation (RouteTravel's box, a
344
+ * route transition) must therefore hold through this module, never through a
345
+ * wrapper of its own.
346
+ */
347
+
348
+
349
+ let renderingHold = null;
350
+ const holdRendering = () => {
351
+ if (renderingHold) {
352
+ return renderingHold.release;
353
+ }
354
+ const debounceRenderingBefore = options.debounceRendering;
355
+ const hold = {
356
+ render: null,
357
+ waiting: [],
358
+ release: () => {
359
+ // Only the hold that is still standing may be given back: a holder
360
+ // releasing after another has taken over must not let go of what it
361
+ // does not hold.
362
+ if (renderingHold !== hold) {
363
+ return;
364
+ }
365
+ renderingHold = null;
366
+ options.debounceRendering = debounceRenderingBefore;
367
+ const { render, waiting } = hold;
368
+ hold.render = null;
369
+ hold.waiting = [];
370
+ if (render) {
371
+ render();
372
+ }
373
+ for (const wait of waiting) {
374
+ wait();
375
+ }
376
+ },
377
+ };
378
+ renderingHold = hold;
379
+ options.debounceRendering = (render) => {
380
+ hold.render = render;
381
+ };
382
+ return hold.release;
383
+ };
384
+
385
+ // Anything else that must not happen before the picture is taken, and the
386
+ // scroll is the other one: a page one arrives at starts at its top, and the
387
+ // document put back to its top while the page being left is still on screen is
388
+ // a page that has ALREADY jumped when the picture is taken. Worse, the browser
389
+ // paints what the new offset shows and nothing else, so the picture keeps only
390
+ // the band it had already painted — the page being left is then seen in
391
+ // fragments, whatever the movement does afterwards.
392
+ //
393
+ // Run at once when nobody is photographing anything, which is the common case
394
+ // and must stay free.
395
+ const whenRenderingResumes = (callback) => {
396
+ if (!renderingHold) {
397
+ callback();
398
+ return;
399
+ }
400
+ renderingHold.waiting.push(callback);
401
+ };
402
+
403
+ // The hold a navigation takes on its way in — from before its first write,
404
+ // because by the time a route announces that it matches, Preact has already
405
+ // been told and the render is queued; a hold taken then is a hold taken too
406
+ // late. Kept here until whoever animates the change takes it over, or the
407
+ // navigation turns out to be one nobody animates.
408
+ let routingRenderingHold = null;
409
+ const holdRenderingForRouting = () => {
410
+ routingRenderingHold = holdRendering();
411
+ };
412
+ // Nobody had a picture to take: a page held for a change it does not animate
413
+ // is a page that stutters for nothing.
414
+ const releaseRoutingRenderingHold = () => {
415
+ const release = routingRenderingHold;
416
+ routingRenderingHold = null;
417
+ if (release) {
418
+ release();
419
+ }
420
+ };
421
+ // An animator takes the navigation's hold as its own — taking another would be
422
+ // taking a hold on a page that is holding still — or takes a fresh one when
423
+ // the change it animates is not a navigation.
424
+ const takeoverRoutingRenderingHold = () => {
425
+ const release = routingRenderingHold || holdRendering();
426
+ routingRenderingHold = null;
427
+ return release;
428
+ };
429
+
301
430
  installImportMetaCssBuild(import.meta);
302
431
 
303
432
  const URL_TARGET_ATTRIBUTE = "data-url-target";
@@ -347,7 +476,8 @@ let urlTargetOptions = {
347
476
  * How long, in ms, to keep waiting for a target that has not arrived, counted
348
477
  * from the moment the document stops working.
349
478
  * @param {number} [options.maxWait=10000]
350
- * Longest wait, in ms, for a document that never stops working.
479
+ * Longest wait, in ms, for a document that never stops working. The element
480
+ * is then answered as it stands, when it is there.
351
481
  */
352
482
  const setUrlTargetOptions = (options) => {
353
483
  urlTargetOptions = { ...urlTargetOptions, ...options };
@@ -374,6 +504,23 @@ const useUrlTargetId = () => {
374
504
  let stopWaitingForCurrentTarget = null;
375
505
  let currentTargetKey;
376
506
 
507
+ const documentLoadIsReturn = () => {
508
+ const [navigationEntry] = performance.getEntriesByType("navigation");
509
+ if (!navigationEntry) {
510
+ return false;
511
+ }
512
+ return (
513
+ navigationEntry.type === "reload" || navigationEntry.type === "back_forward"
514
+ );
515
+ };
516
+ // The url the document is on its way back to — a back, a forward, a reload —
517
+ // where the reader was, as opposed to a place just sent to. Said by the
518
+ // navigation before it writes anything, and by the load for the first url.
519
+ let returningToUrl = documentLoadIsReturn() ? window.location.href : null;
520
+ observeBeforeRouting(({ url, navigationType }) => {
521
+ returningToUrl = navigationType === "traverse" ? url : null;
522
+ });
523
+
377
524
  /**
378
525
  * Answers the URL's target again, as if it had just been designated.
379
526
  *
@@ -383,10 +530,10 @@ let currentTargetKey;
383
530
  */
384
531
  const rearmUrlTarget = () => {
385
532
  currentTargetKey = undefined;
386
- armUrlTarget(documentUrlSignal.peek());
533
+ armUrlTarget(documentUrlSignal.peek(), { isReturn: false });
387
534
  };
388
535
 
389
- const armUrlTarget = (documentUrl) => {
536
+ const armUrlTarget = (documentUrl, { isReturn }) => {
390
537
  const targetKey = urlToTargetKey(documentUrl);
391
538
  if (targetKey === currentTargetKey) {
392
539
  return;
@@ -397,11 +544,25 @@ const armUrlTarget = (documentUrl) => {
397
544
  stopWaitingForCurrentTarget = null;
398
545
  }
399
546
  const targetId = urlToTargetId(documentUrl);
547
+ // The browser leaves `:target` on the element a URL named once the URL has
548
+ // stopped naming it — a pushState never touches it.
549
+ const staleTarget = document.querySelector(":target");
550
+ if (staleTarget && staleTarget.id !== targetId) {
551
+ writeTarget(null);
552
+ }
400
553
  if (!targetId) {
401
554
  return;
402
555
  }
403
- stopWaitingForCurrentTarget = waitForElementWithId(targetId, (element) => {
556
+ stopWaitingForCurrentTarget = waitForUrlTarget(targetId, (element) => {
404
557
  stopWaitingForCurrentTarget = null;
558
+ if (!element.matches(":target")) {
559
+ writeTarget(element);
560
+ }
561
+ if (isReturn) {
562
+ // The offset put back by scroll_restoration.js is the reader's own: the
563
+ // element is named by the URL, not arrived at.
564
+ return;
565
+ }
405
566
  revealUrlTarget(element);
406
567
  });
407
568
  };
@@ -418,14 +579,27 @@ const urlToTargetId = (url) => {
418
579
  return hash ? decodeURIComponent(hash.slice(1)) : "";
419
580
  };
420
581
 
421
- const waitForElementWithId = (id, onFound) => {
582
+ // Calls `onSettled` with the element once it can be answered: it exists, it
583
+ // shows something, and the document is done working. Found is not enough while
584
+ // the document works — what sits above it may still be drawn and push it down,
585
+ // and a page reusing a node of the page being left holds it before its own
586
+ // data has come.
587
+ //
588
+ // Confirmed at the next frame, once rendering has resumed: the render the last
589
+ // answer asked for has run by then, and a component starting its own run from
590
+ // that render has made the document busy again.
591
+ const waitForUrlTarget = (id, onSettled) => {
592
+ const { graceAfterIdle, maxWait } = urlTargetOptions;
593
+ let stopped = false;
594
+ let confirmationPending = false;
595
+ let frame = null;
422
596
  let mutationObserver = null;
423
597
  let stopWatchingBusy = null;
424
598
  let idleTimeout = null;
425
599
  let maxWaitTimeout = null;
426
- let found = false;
427
600
 
428
601
  const stopWaiting = () => {
602
+ stopped = true;
429
603
  if (mutationObserver) {
430
604
  mutationObserver.disconnect();
431
605
  mutationObserver = null;
@@ -436,76 +610,214 @@ const waitForElementWithId = (id, onFound) => {
436
610
  }
437
611
  clearTimeout(idleTimeout);
438
612
  clearTimeout(maxWaitTimeout);
613
+ if (frame !== null) {
614
+ cancelAnimationFrame(frame);
615
+ frame = null;
616
+ }
439
617
  };
440
618
 
441
- const checkForElement = () => {
619
+ const findElement = () => {
442
620
  const element = document.getElementById(id);
443
621
  if (!element) {
444
- return;
622
+ return null;
445
623
  }
446
624
  // Rendered inside a closed tab, a folded details, a view that is not the
447
- // one on screen: the element exists but would show nothing. Keep waiting —
448
- // it is the same wait, for the same reason.
625
+ // one on screen: the element exists but would show nothing.
449
626
  if (element.checkVisibility && !element.checkVisibility()) {
450
- return;
627
+ return null;
451
628
  }
452
- found = true;
629
+ return element;
630
+ };
631
+ const settle = (element) => {
453
632
  stopWaiting();
454
- onFound(element);
633
+ onSettled(element);
634
+ };
635
+ const check = () => {
636
+ if (confirmationPending || documentIsBusySignal.peek() || !findElement()) {
637
+ return;
638
+ }
639
+ confirmationPending = true;
640
+ whenRenderingResumes(() => {
641
+ if (stopped) {
642
+ return;
643
+ }
644
+ frame = requestAnimationFrame(() => {
645
+ frame = null;
646
+ confirmationPending = false;
647
+ if (documentIsBusySignal.peek()) {
648
+ return;
649
+ }
650
+ const element = findElement();
651
+ if (element) {
652
+ settle(element);
653
+ }
654
+ });
655
+ });
455
656
  };
456
657
 
457
- checkForElement();
458
- if (found) {
459
- return stopWaiting;
460
- }
461
-
462
- mutationObserver = new MutationObserver(checkForElement);
658
+ mutationObserver = new MutationObserver(check);
659
+ // Every attribute, not only `id`: what shows an element that is already
660
+ // there — a <details> opening, a tab panel losing its display: none — is an
661
+ // attribute written somewhere above it, and nothing else says so.
463
662
  mutationObserver.observe(document.documentElement, {
464
663
  childList: true,
465
664
  subtree: true,
466
665
  attributes: true,
467
- attributeFilter: ["id"],
468
666
  });
469
- const { graceAfterIdle, maxWait } = urlTargetOptions;
470
667
  stopWatchingBusy = effect(() => {
471
668
  const documentIsBusy = documentIsBusySignal.value;
472
669
  clearTimeout(idleTimeout);
473
- if (!documentIsBusy) {
474
- idleTimeout = setTimeout(stopWaiting, graceAfterIdle);
670
+ if (documentIsBusy) {
671
+ return;
475
672
  }
476
- });
477
- maxWaitTimeout = setTimeout(stopWaiting, maxWait);
673
+ check();
674
+ // The grace is for an element that has not come; one already there and
675
+ // waiting for rendering to resume is not given up on.
676
+ idleTimeout = setTimeout(() => {
677
+ if (!confirmationPending) {
678
+ stopWaiting();
679
+ }
680
+ }, graceAfterIdle);
681
+ });
682
+ // A document that never stops working: the element as it stands, when it is
683
+ // there at all.
684
+ maxWaitTimeout = setTimeout(() => {
685
+ const element = findElement();
686
+ if (element) {
687
+ settle(element);
688
+ } else {
689
+ stopWaiting();
690
+ }
691
+ }, maxWait);
478
692
 
479
693
  return stopWaiting;
480
694
  };
481
695
 
482
696
  const revealUrlTarget = (element) => {
483
697
  const { block, behavior, markDuration } = urlTargetOptions;
484
- // The element just entered the DOM: where it sits is only known once layout
485
- // has run.
486
- requestAnimationFrame(() => {
487
- const prefersReducedMotion = window.matchMedia(
488
- "(prefers-reduced-motion: reduce)",
489
- ).matches;
490
- element.scrollIntoView({
491
- block,
492
- behavior: prefersReducedMotion ? "instant" : behavior,
493
- });
494
- // What the browser does when it handles a fragment itself: keyboard
495
- // navigation resumes from the target, not from the top of the document.
496
- if (elementIsFocusable(element)) {
497
- element.focus({ preventScroll: true });
698
+ const prefersReducedMotion = window.matchMedia(
699
+ "(prefers-reduced-motion: reduce)",
700
+ ).matches;
701
+ element.scrollIntoView({
702
+ block,
703
+ behavior: prefersReducedMotion ? "instant" : behavior,
704
+ });
705
+ // What the browser does when it handles a fragment itself: keyboard
706
+ // navigation resumes from the target, not from the top of the document.
707
+ if (elementIsFocusable(element)) {
708
+ element.focus({ preventScroll: true });
709
+ }
710
+ element.setAttribute(URL_TARGET_ATTRIBUTE, "");
711
+ setTimeout(() => {
712
+ element.removeAttribute(URL_TARGET_ATTRIBUTE);
713
+ }, markDuration);
714
+ };
715
+
716
+ let targetWriteInProgress = false;
717
+ /**
718
+ * True while navi writes the browser's `:target`. The fragment navigation that
719
+ * takes is announced like any other — a popstate, a navigate event, an entry
720
+ * change — and names the address and the state the document is already at, so
721
+ * whoever routes on those events leaves it alone.
722
+ */
723
+ const isTargetWriteInProgress = () => {
724
+ return targetWriteInProgress;
725
+ };
726
+
727
+ // `:target` onto `element`, or off whatever holds it when `element` is null.
728
+ //
729
+ // A replace towards the address the browser is at: the browser answers it as a
730
+ // fragment navigation, and that is the only moment it sets `:target`. It also
731
+ // scrolls to what the fragment designates, resets the entry's states
732
+ // (history.state in Firefox, the Navigation API state everywhere) and fires a
733
+ // popstate (Chrome, Safari) and a navigate event. The address, both states and
734
+ // the scroll are put back in the same task, so nothing of it is painted; a
735
+ // running view transition is not skipped by it (measured in Chrome, Firefox
736
+ // and Safari).
737
+ const writeTarget = (element) => {
738
+ const address = window.location.href;
739
+ const historyState = window.history.state;
740
+ const { navigation } = window;
741
+ const entry = navigation ? navigation.currentEntry : null;
742
+ const entryState = entry ? entry.getState() : undefined;
743
+ const putScrollBack = captureScroll(element);
744
+ const addressFragment = new URL(address).hash.slice(1);
745
+ const addressTargetId = decodeURIComponent(addressFragment);
746
+
747
+ let fragmentUrl;
748
+ let silencedElement = null;
749
+ if (element) {
750
+ fragmentUrl =
751
+ element.id === addressTargetId
752
+ ? address
753
+ : `${urlWithoutFragment(address)}#${encodeURIComponent(element.id)}`;
754
+ } else if (addressFragment) {
755
+ // The address itself, so the fragment does not change and no hashchange
756
+ // follows; the element it names briefly without its id, so it designates
757
+ // nothing.
758
+ fragmentUrl = address;
759
+ silencedElement = document.getElementById(addressTargetId);
760
+ } else {
761
+ // An empty fragment designates the top of the document; the scroll it
762
+ // makes is put back with the others.
763
+ fragmentUrl = `${address}#`;
764
+ }
765
+
766
+ targetWriteInProgress = true;
767
+ try {
768
+ if (silencedElement) {
769
+ silencedElement.removeAttribute("id");
498
770
  }
499
- element.setAttribute(URL_TARGET_ATTRIBUTE, "");
500
- setTimeout(() => {
501
- element.removeAttribute(URL_TARGET_ATTRIBUTE);
502
- }, markDuration);
771
+ window.location.replace(fragmentUrl);
772
+ } finally {
773
+ if (silencedElement) {
774
+ silencedElement.id = addressTargetId;
775
+ }
776
+ window.history.replaceState(historyState, null, address);
777
+ if (entry && navigation.currentEntry) {
778
+ navigation.updateCurrentEntry({ state: entryState });
779
+ }
780
+ targetWriteInProgress = false;
781
+ }
782
+ putScrollBack();
783
+ };
784
+
785
+ // Every box a fragment navigation towards `element` may scroll: its ancestors,
786
+ // and the document.
787
+ const captureScroll = (element) => {
788
+ const boxes = [];
789
+ let box = element ? element.parentElement : null;
790
+ while (box) {
791
+ boxes.push(box);
792
+ box = box.parentElement;
793
+ }
794
+ const { scrollingElement } = document;
795
+ if (!boxes.includes(scrollingElement)) {
796
+ boxes.push(scrollingElement);
797
+ }
798
+ const positions = boxes.map((box) => {
799
+ return { box, left: box.scrollLeft, top: box.scrollTop };
503
800
  });
801
+ return () => {
802
+ for (const { box, left, top } of positions) {
803
+ if (box.scrollLeft !== left || box.scrollTop !== top) {
804
+ box.scrollTo({ left, top, behavior: "instant" });
805
+ }
806
+ }
807
+ };
808
+ };
809
+
810
+ const urlWithoutFragment = (url) => {
811
+ const urlObject = new URL(url);
812
+ urlObject.hash = "";
813
+ return urlObject.href;
504
814
  };
505
815
 
506
816
  effect(() => {
507
817
  const documentUrl = documentUrlSignal.value;
508
- armUrlTarget(documentUrl);
818
+ const isReturn = returningToUrl === documentUrl;
819
+ returningToUrl = null;
820
+ armUrlTarget(documentUrl, { isReturn });
509
821
  });
510
822
 
511
823
  /**
@@ -10103,6 +10415,26 @@ const dispatchRequestInteraction = (
10103
10415
  });
10104
10416
  };
10105
10417
 
10418
+ /**
10419
+ * Whether the control `element` belongs to lets an interaction of this intent
10420
+ * through, asked ahead of the event the gate will judge, and silently: nothing
10421
+ * is reported, nothing runs. For what a press is about to lead to (see
10422
+ * announceOpeningPress in commands.js) — the press itself is not that event: a
10423
+ * drag source cancels a `pointerdown` whose click still comes and opens.
10424
+ *
10425
+ * @param {Element} element
10426
+ * @param {{ intent: "read" | "write" }} options
10427
+ * @returns {boolean}
10428
+ */
10429
+ const allowsInteraction = (element, { intent }) => {
10430
+ const controlHost = findControlHost(element) || element;
10431
+ const ci = controlHost.__uiStateController__?.rules.interaction;
10432
+ if (!ci) {
10433
+ return true;
10434
+ }
10435
+ return ci.checkInteractivity({ intent });
10436
+ };
10437
+
10106
10438
  const onRequestInteraction = (
10107
10439
  requestInteractionCustomEvent,
10108
10440
  { debugInteraction },
@@ -17751,6 +18083,33 @@ const syncOwnedResourceToSignals = (
17751
18083
 
17752
18084
  // Global signal registry for route template detection
17753
18085
  const globalSignalRegistry = new Map();
18086
+
18087
+ // The values some states are about to hold, answered in place of what they
18088
+ // hold for the length of one readStateAsIf.
18089
+ let valuesAsIf = null;
18090
+ /**
18091
+ * Runs `read` as if each state in `values` held the value given for it: the
18092
+ * address a press is about to write, read before it is written. Nothing is
18093
+ * written, so whoever watches those states hears nothing, and `read`
18094
+ * subscribes to nothing.
18095
+ *
18096
+ * Only a read of the state itself is answered this way. A `computed` derived
18097
+ * from one answers what it last computed — or, evaluated for the first time
18098
+ * inside `read`, caches a value derived from this answer: `read` reads the
18099
+ * states it depends on directly (a route action's params do).
18100
+ *
18101
+ * @param {Map<import("@preact/signals").Signal, any>} values
18102
+ * @param {() => any} read
18103
+ */
18104
+ const readStateAsIf = (values, read) => {
18105
+ const previous = valuesAsIf;
18106
+ valuesAsIf = values;
18107
+ try {
18108
+ return untracked(read);
18109
+ } finally {
18110
+ valuesAsIf = previous;
18111
+ }
18112
+ };
17754
18113
  let signalIdCounter = 0;
17755
18114
  const generateSignalId = () => {
17756
18115
  const id = signalIdCounter++;
@@ -18074,6 +18433,9 @@ const stateSignal = (defaultValue, options = {}) => {
18074
18433
  const valueDescriptor = Object.getOwnPropertyDescriptor(signalProto, "value");
18075
18434
  Object.defineProperty(preactSignal, "value", {
18076
18435
  get() {
18436
+ if (valuesAsIf !== null && valuesAsIf.has(preactSignal)) {
18437
+ return valuesAsIf.get(preactSignal);
18438
+ }
18077
18439
  return valueDescriptor.get.call(preactSignal);
18078
18440
  },
18079
18441
  set(newValue) {
@@ -26332,7 +26694,8 @@ const route = (
26332
26694
  };
26333
26695
  // Asks ahead of the arrival for what this route's actions ask without the
26334
26696
  // address — a page's code, a read without params (see routeAction). An
26335
- // action keyed by a param waits for the arrival: a prefetch has no address.
26697
+ // action keyed by a param waits for the arrival: a prefetch has no address
26698
+ // (preloadState is the one that has one).
26336
26699
  route.preload = () => {
26337
26700
  const preloadSet = routePreloadMap.get(route);
26338
26701
  if (!preloadSet) {
@@ -26899,6 +27262,40 @@ const preloadUrl = (url) => {
26899
27262
  }
26900
27263
  }
26901
27264
  };
27265
+
27266
+ const routeStatePreloadMap = new WeakMap();
27267
+ const registerRouteStatePreload = (route, preload) => {
27268
+ let preloadSet = routeStatePreloadMap.get(route);
27269
+ if (!preloadSet) {
27270
+ preloadSet = new Set();
27271
+ routeStatePreloadMap.set(route, preloadSet);
27272
+ }
27273
+ preloadSet.add(preload);
27274
+ };
27275
+ /**
27276
+ * Preloads what the routes one is on will ask for once `signal` holds
27277
+ * `value`: the address a popup's opening is about to write (see
27278
+ * open_controller.js), known at the press that opens it. Only the routes
27279
+ * matching now are asked — writing a state keeps one on the same page.
27280
+ */
27281
+ const preloadState = (signal, value) => {
27282
+ if (!activeRouteSet) {
27283
+ return;
27284
+ }
27285
+ const values = new Map([[signal, value]]);
27286
+ for (const route of activeRouteSet) {
27287
+ if (!route.matchingSignal.peek()) {
27288
+ continue;
27289
+ }
27290
+ const preloadSet = routeStatePreloadMap.get(route);
27291
+ if (!preloadSet) {
27292
+ continue;
27293
+ }
27294
+ for (const preload of preloadSet) {
27295
+ preload(values);
27296
+ }
27297
+ }
27298
+ };
26902
27299
  const setupRoutes = (routes) => {
26903
27300
  if (setupRoutesCalled) {
26904
27301
  throw new Error(
@@ -27473,112 +27870,6 @@ const dropGeneratedIdKeys = (state) => {
27473
27870
  return stateWithoutGeneratedIdKeys;
27474
27871
  };
27475
27872
 
27476
- /**
27477
- * The document's rendering, held for the one frame a view transition needs.
27478
- *
27479
- * The browser does not take the picture of the page being left when a
27480
- * transition is ASKED for — it takes it at the next frame, just before running
27481
- * the update callback. Preact renders sooner than that, in a microtask: so a
27482
- * change nobody asked for (a tab pressed, the back button) has already reached
27483
- * the DOM when the picture is taken, and the picture is of the page ARRIVING.
27484
- * Both sides of the animation then show it, and one watches a page slide onto
27485
- * itself.
27486
- *
27487
- * So what Preact has queued waits until the update callback, which is the
27488
- * moment the API is built around — the change belongs inside it. The whole
27489
- * document is held: it is about to be frozen under a picture anyway.
27490
- *
27491
- * ONE hold for the whole document, whoever animates. The hold is a wrapper
27492
- * around Preact's `options.debounceRendering`, and two of them installed
27493
- * independently restore each other in the wrong order when they let go — every
27494
- * render queued in between is then handed to a wrapper nobody will ever
27495
- * release. Everything that photographs a navigation (RouteTravel's box, a
27496
- * route transition) must therefore hold through this module, never through a
27497
- * wrapper of its own.
27498
- */
27499
-
27500
-
27501
- let renderingHold = null;
27502
- const holdRendering = () => {
27503
- if (renderingHold) {
27504
- return renderingHold.release;
27505
- }
27506
- const debounceRenderingBefore = options.debounceRendering;
27507
- const hold = {
27508
- render: null,
27509
- waiting: [],
27510
- release: () => {
27511
- // Only the hold that is still standing may be given back: a holder
27512
- // releasing after another has taken over must not let go of what it
27513
- // does not hold.
27514
- if (renderingHold !== hold) {
27515
- return;
27516
- }
27517
- renderingHold = null;
27518
- options.debounceRendering = debounceRenderingBefore;
27519
- const { render, waiting } = hold;
27520
- hold.render = null;
27521
- hold.waiting = [];
27522
- if (render) {
27523
- render();
27524
- }
27525
- for (const wait of waiting) {
27526
- wait();
27527
- }
27528
- },
27529
- };
27530
- renderingHold = hold;
27531
- options.debounceRendering = (render) => {
27532
- hold.render = render;
27533
- };
27534
- return hold.release;
27535
- };
27536
-
27537
- // Anything else that must not happen before the picture is taken, and the
27538
- // scroll is the other one: a page one arrives at starts at its top, and the
27539
- // document put back to its top while the page being left is still on screen is
27540
- // a page that has ALREADY jumped when the picture is taken. Worse, the browser
27541
- // paints what the new offset shows and nothing else, so the picture keeps only
27542
- // the band it had already painted — the page being left is then seen in
27543
- // fragments, whatever the movement does afterwards.
27544
- //
27545
- // Run at once when nobody is photographing anything, which is the common case
27546
- // and must stay free.
27547
- const whenRenderingResumes = (callback) => {
27548
- if (!renderingHold) {
27549
- callback();
27550
- return;
27551
- }
27552
- renderingHold.waiting.push(callback);
27553
- };
27554
-
27555
- // The hold a navigation takes on its way in — from before its first write,
27556
- // because by the time a route announces that it matches, Preact has already
27557
- // been told and the render is queued; a hold taken then is a hold taken too
27558
- // late. Kept here until whoever animates the change takes it over, or the
27559
- // navigation turns out to be one nobody animates.
27560
- let routingRenderingHold = null;
27561
- const holdRenderingForRouting = () => {
27562
- routingRenderingHold = holdRendering();
27563
- };
27564
- // Nobody had a picture to take: a page held for a change it does not animate
27565
- // is a page that stutters for nothing.
27566
- const releaseRoutingRenderingHold = () => {
27567
- const release = routingRenderingHold;
27568
- routingRenderingHold = null;
27569
- if (release) {
27570
- release();
27571
- }
27572
- };
27573
- // An animator takes the navigation's hold as its own — taking another would be
27574
- // taking a hold on a page that is holding still — or takes a fresh one when
27575
- // the change it animates is not a navigation.
27576
- const takeoverRoutingRenderingHold = () => {
27577
- const release = routingRenderingHold || holdRendering();
27578
- routingRenderingHold = null;
27579
- return release;
27580
- };
27581
-
27582
27873
  /**
27583
27874
  * Where a page was left, given back when one comes back to it.
27584
27875
  *
@@ -27779,6 +28070,11 @@ const restoreScrollPosition = (url) => {
27779
28070
  // So `from`, the url being left, tells an arrival from the same place said
27780
28071
  // differently, and only the first is moved.
27781
28072
  //
28073
+ // A fragment does not make it less of an arrival. The browser finds nothing to
28074
+ // scroll to after a pushState; url_target.js brings the element in once it
28075
+ // renders, from this top — and when it never comes, the page is read from its
28076
+ // top like any other.
28077
+ //
27782
28078
  // The document, because the document is the scrollport in the common case. An
27783
28079
  // app that scrolls an element of its own scrolls it itself.
27784
28080
  const startAtTop = (url, { from } = {}) => {
@@ -27788,14 +28084,10 @@ const startAtTop = (url, { from } = {}) => {
27788
28084
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27789
28085
  };
27790
28086
  const isArrival = (url, { from }) => {
27791
- const urlObject = new URL(url, window.location.href);
27792
- // A fragment names where to land, and the browser is the one that finds it.
27793
- if (urlObject.hash) {
27794
- return false;
27795
- }
27796
28087
  if (
27797
28088
  from !== undefined &&
27798
- new URL(from, window.location.href).pathname === urlObject.pathname
28089
+ new URL(from, window.location.href).pathname ===
28090
+ new URL(url, window.location.href).pathname
27799
28091
  ) {
27800
28092
  return false;
27801
28093
  }
@@ -27872,29 +28164,6 @@ const forgetScrollerUnlessPageLeft = (name) => {
27872
28164
  }
27873
28165
  };
27874
28166
 
27875
- /**
27876
- * A navigation is ABOUT to be applied — said before its very first write.
27877
- *
27878
- * Everything else a router says arrives once the change is made: a route
27879
- * announces that it matches, an action that it is running. That is too late for
27880
- * anyone who needs the page as it stands BEFORE, and the browser's view
27881
- * transitions are exactly that kind of reader — the picture they keep of the
27882
- * page being left is taken at the next frame, and a render answering a signal
27883
- * written a moment ago is already in the DOM by then (see route_travel.jsx).
27884
- *
27885
- * So this is the one moment where nothing has moved yet. It is published
27886
- * synchronously, from the top of the navigation, and whoever listens runs
27887
- * before the URL, the visited set, or any route has changed.
27888
- *
27889
- * The other end is published too, and for the same kind of reader: whoever
27890
- * held something across the change and has nobody to hand it to gets a moment
27891
- * to let go of it that does not depend on guessing how long the change takes.
27892
- */
27893
-
27894
-
27895
- const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
27896
- const [publishAfterRouting, observeAfterRouting] = createPubSub();
27897
-
27898
28167
  const getHrefTargetInfo = (href) => {
27899
28168
  href = String(href);
27900
28169
 
@@ -28402,6 +28671,11 @@ const setupBrowserIntegrationViaHistory = ({
28402
28671
  });
28403
28672
 
28404
28673
  window.addEventListener("popstate", (popstateEvent) => {
28674
+ if (isTargetWriteInProgress()) {
28675
+ // The fragment navigation navi makes to set `:target` (see
28676
+ // url_target.js): the entry keeps its address and its state.
28677
+ return;
28678
+ }
28405
28679
  // The entry a pending replace was for is no longer the current one, and
28406
28680
  // the History API has no way to write it (see replaceAddressWhenSettled).
28407
28681
  dropPendingReplace();
@@ -28477,9 +28751,14 @@ const setupBrowserIntegrationViaHistory = ({
28477
28751
  // one), so whoever awaits this reads a document url and state that
28478
28752
  // already say where it landed.
28479
28753
  const landedPromise = new Promise((resolve) => {
28480
- window.addEventListener("popstate", () => resolve(true), {
28481
- once: true,
28482
- });
28754
+ const onPopstate = () => {
28755
+ if (isTargetWriteInProgress()) {
28756
+ return;
28757
+ }
28758
+ window.removeEventListener("popstate", onPopstate);
28759
+ resolve(true);
28760
+ };
28761
+ window.addEventListener("popstate", onPopstate);
28483
28762
  });
28484
28763
  landOnPending = landOn || null;
28485
28764
  window.history.back();
@@ -33300,10 +33579,15 @@ const RouteTravel = ({
33300
33579
  // stopping. Which end decides how it is answered, and the two are not the
33301
33580
  // same amount of work.
33302
33581
  onEdge: ({
33303
- sign
33582
+ sign,
33583
+ thrown
33304
33584
  }) => {
33305
33585
  const travel = travelRef.current;
33306
- if (!travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
33586
+ if (
33587
+ // Thrown on before its end: handing over from there would mean jumping
33588
+ // the pictures the rest of the way first (see scrubTravel below), a
33589
+ // page seen leaping. The travel caught simply arrives.
33590
+ thrown || !travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
33307
33591
  return false;
33308
33592
  }
33309
33593
  // Where the page in hand is coming from, said the way the gesture says
@@ -33880,10 +34164,14 @@ const writeCover = (edge, element, size) => {
33880
34164
  * the address, which is also what makes it worth fetching AHEAD of the
33881
34165
  * arrival: a link to the route preruns it when the pointer or the focus
33882
34166
  * reaches the link (see docs/dynamic_import.md). An action whose params
33883
- * come from the address is never prefetched — a prefetch has no address.
34167
+ * come from the address is not prefetched by a link — a link's prefetch has
34168
+ * no address. It is by the press that opens a popup bound to a state these
34169
+ * params read: that address is the current one with the state set to the
34170
+ * popup's value (see `prefetch` on Button).
33884
34171
  * @param {object} [options]
33885
- * @param {boolean} [options.prefetch=true] - `false` keeps a param-less action
33886
- * from being prerun on intent, when the read is not worth a hover.
34172
+ * @param {boolean} [options.prefetch=true] - `false` keeps the action from
34173
+ * being prerun ahead of what asks for it, when the read is not worth a
34174
+ * hover or a press that may not end in an opening.
33887
34175
  */
33888
34176
  const routeAction = (
33889
34177
  routeOrRoutes,
@@ -33912,27 +34200,47 @@ const routeAction = (
33912
34200
  },
33913
34201
  options,
33914
34202
  );
33915
- if (!paramsEffect && prefetch) {
34203
+ if (prefetch) {
33916
34204
  for (const route of routes) {
33917
- registerRoutePreload(route, () => {
33918
- prefetchParamless(action);
33919
- });
34205
+ if (paramsEffect) {
34206
+ registerRouteStatePreload(route, (values) => {
34207
+ prefetchAsIf(action, paramsEffect, values);
34208
+ });
34209
+ } else {
34210
+ registerRoutePreload(route, () => {
34211
+ prerunAhead(action.bindParams(true));
34212
+ });
34213
+ }
33920
34214
  }
33921
34215
  }
33922
34216
  return actionBoundToRoute;
33923
34217
  };
33924
34218
 
33925
- // The instance the effect above runs on the match is the one bound to `true`
33926
- // (the default params), so a prerun here is what the arrival promotes to a
33927
- // run. A prefetch that fails is not reported: nothing on screen asked for it,
33928
- // and the failure stays on the instance, where a run asks again — a FAILED
33929
- // action is run, only RUNNING and COMPLETED are left alone — and the arrival
33930
- // shows its own. It is left FAILED on purpose, not reset: the press that
33931
- // brings the arrival also focuses the link, so a prerun can be in flight when
33932
- // the arrival promotes it, and a reset landing after that would pull the
33933
- // answer from under the page that is reading it.
33934
- const prefetchParamless = (action) => {
33935
- const instance = action.bindParams(true);
34219
+ // The params as they will read once the states hold what they are about to
34220
+ // (see readStateAsIf), prerun only when that changes them: params the state
34221
+ // leaves alone are the ones the action already runs on — or is meant not to,
34222
+ // and a prerun would retry a FAILED one behind the screen's back.
34223
+ const prefetchAsIf = (action, paramsEffect, values) => {
34224
+ const params = readStateAsIf(values, paramsEffect);
34225
+ if (!params) {
34226
+ return;
34227
+ }
34228
+ if (compareTwoJsValues(params, untracked(paramsEffect))) {
34229
+ return;
34230
+ }
34231
+ prerunAhead(action.bindParams(params));
34232
+ };
34233
+
34234
+ // The instance prerun is the one the arrival runs on — the one bound to the
34235
+ // params it reads (`true` for a param-less action) — so the arrival promotes
34236
+ // it to a run. A prefetch that fails is not reported: nothing on screen asked
34237
+ // for it, and the failure stays on the instance, where a run asks again — a
34238
+ // FAILED action is run, only RUNNING and COMPLETED are left alone — and the
34239
+ // arrival shows its own. It is left FAILED on purpose, not reset: a prerun is
34240
+ // often still in flight when the arrival promotes it (the press that brings
34241
+ // the arrival focuses the link, or is the one that asked), and a reset
34242
+ // landing after that would pull the answer from under the page reading it.
34243
+ const prerunAhead = (instance) => {
33936
34244
  let result;
33937
34245
  try {
33938
34246
  result = instance.prerun({ reason: "intent" });
@@ -34930,6 +35238,30 @@ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
34930
35238
  },
34931
35239
  };
34932
35240
  });
35241
+ /**
35242
+ * Tells the popup a source opens that a press on it has started, ahead of the
35243
+ * release that runs the command: what the opening will read can be asked for
35244
+ * now (see open_controller.js). A press rather than a hover, because what
35245
+ * that reads is keyed by the popup — one sheet per card — and a pointer
35246
+ * crossing a list of cards has chosen none of them; on a touch screen the
35247
+ * press is the first thing the finger says anyway.
35248
+ *
35249
+ * The target is found the way --navi-open finds it, but silently: nothing
35250
+ * runs here, and a source with no target says so at the release.
35251
+ */
35252
+ const announceOpeningPress = (source, command) => {
35253
+ if (command !== "--navi-open" && command !== "--navi-toggle") {
35254
+ return;
35255
+ }
35256
+ const commandFor = source.getAttribute("commandfor");
35257
+ const target = commandFor
35258
+ ? document.getElementById(commandFor)
35259
+ : resolveExplicitTarget(source) || resolveClosestExpandable(source);
35260
+ if (!target) {
35261
+ return;
35262
+ }
35263
+ dispatchCustomEvent(target, "navi_open_press");
35264
+ };
34933
35265
  // "--navi-close:all" closes every expandable above the source, nearest first —
34934
35266
  // a link leaving from a badge shown over a sheet leaves both. A surface that
34935
35267
  // refuses (a form asking about its changes) keeps what is above it open too:
@@ -36383,7 +36715,7 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
36383
36715
  return;
36384
36716
  }
36385
36717
  if (opened) {
36386
- signal.value = popupValue === undefined ? true : popupValue;
36718
+ signal.value = readOpenValue(popupValue);
36387
36719
  return;
36388
36720
  }
36389
36721
  const closedValue = readClosedValue(signalValue);
@@ -36415,6 +36747,9 @@ const readOpened = (signalValue, popupValue) => {
36415
36747
  }
36416
36748
  return signalValue === popupValue;
36417
36749
  };
36750
+ const readOpenValue = (popupValue) => {
36751
+ return popupValue === undefined ? true : popupValue;
36752
+ };
36418
36753
  const readClosedValue = (openSignalValue) => {
36419
36754
  return openSignalValue === true ? false : undefined;
36420
36755
  };
@@ -36488,6 +36823,29 @@ const useOpenPropsEffectOnOpenController = (
36488
36823
  }
36489
36824
  }
36490
36825
  : null;
36826
+ // A press that will open this popup has started (see announceOpeningPress
36827
+ // in commands.js). What the opening writes — the popup's value, in its
36828
+ // signal — is known before the release, so what the screen reads off it is
36829
+ // asked for now (see preloadState).
36830
+ useEffect(() => {
36831
+ if (!signal) {
36832
+ return undefined;
36833
+ }
36834
+ const element = openController.getElement?.();
36835
+ if (!element) {
36836
+ return undefined;
36837
+ }
36838
+ const onOpenPress = () => {
36839
+ if (openController.opened) {
36840
+ return;
36841
+ }
36842
+ preloadState(signal, readOpenValue(value));
36843
+ };
36844
+ element.addEventListener("navi_open_press", onOpenPress);
36845
+ return () => {
36846
+ element.removeEventListener("navi_open_press", onOpenPress);
36847
+ };
36848
+ }, [signal, value]);
36491
36849
  // Tracks whether the effect below has ever run before — only the very
36492
36850
  // first run gets the "mount already open" treatment (`open` truthy from
36493
36851
  // the start, or the uncontrolled, mount-only `defaultOpen`); every
@@ -37182,17 +37540,36 @@ const armOutsidePressClose = (
37182
37540
  * content ahead of the click that will open it. Deferring the build to the
37183
37541
  * opening puts its whole cost in the frame right after the click — the frame
37184
37542
  * where a delay is felt hardest — while the ~100-300ms between hovering a
37185
- * trigger and pressing it are free. The warming render is asynchronous
37186
- * (batched, not flushed): nothing here needs the content in the DOM before
37187
- * the click, only before the open that follows it.
37188
- *
37189
- * "while-opened" content is never warmed. That mode promises two things
37190
- * warming would break: the content is built at open time (so a `defaultValue`
37191
- * read at build time is fresh, not seeded at pointer-enter time), and it is
37192
- * only ever mounted between an open and a close — unmounting happens on close,
37193
- * so a warmed popup that never opens would keep its content in the document
37194
- * indefinitely. Callers lean on that guarantee (e.g. several pickers sharing
37195
- * one set of content ids because only one content exists at a time).
37543
+ * trigger and pressing it are free. The warming render starts at the frame
37544
+ * after the one that follows the intent: nothing here needs the content in the
37545
+ * DOM before the click, only before the open that follows it, and on a touch
37546
+ * screen `pointerenter` arrives together with the `pointerdown` — a build
37547
+ * started there would hold back the frame that shows the press. Two animation
37548
+ * frames, never a timeout: browsers hold timers back while a finger is down,
37549
+ * and a timeout started by the press fires at its release, on top of the click.
37550
+ *
37551
+ * "while-opened" content is warmed by an opening press, never by a hover or a
37552
+ * focus. That mode promises content built fresh for the gesture that opens
37553
+ * it, and mounted only while that gesture and its opening last: callers lean
37554
+ * on it (several pickers sharing one set of content ids, because only one
37555
+ * content exists at a time). A pointer crossing four triggers would build four
37556
+ * contents; a press is the start of one opening.
37557
+ *
37558
+ * Only what opens the popup can say its press is one, and it says so as the
37559
+ * press starts, with a `navi_open_press` on the popup (see
37560
+ * announceOpeningPress in commands.js): a `--navi-open` button, a picker's
37561
+ * trigger when a press is what opens it, an expandable's UI part. Not the
37562
+ * anchor: it says where the popup is placed, not what opens it — a card
37563
+ * opened by a hold is pressed all day by taps meant for what it holds.
37564
+ *
37565
+ * The press builds the content, and the press ending without an open throws
37566
+ * it away — its click reaching the document with the popup still closed, no
37567
+ * click coming after the release, the browser taking the gesture
37568
+ * (`pointercancel`), or the next press starting, before any of its own
37569
+ * handlers run: one press at a time holds a content. A keyboard opening builds
37570
+ * at open time. A popup with an `onOpen` is never warmed: that callback runs
37571
+ * before the content is built (see open_controller.js), and a content seeding
37572
+ * itself from what it writes must be built after it.
37196
37573
  */
37197
37574
 
37198
37575
 
@@ -37234,26 +37611,35 @@ const usePopupContentMount = (
37234
37611
  { mount = MOUNT_DEFAULT, anchor },
37235
37612
  ) => {
37236
37613
  const mountedAlways = mount === "always";
37237
- const [contentMounted, setContentMounted] = useState(
37614
+ const [contentMounted, setContentMountedState] = useState(
37238
37615
  () => mountedAlways || openController.opened,
37239
37616
  );
37240
- openController.mountContent = contentMounted
37241
- ? null
37242
- : () => {
37243
- const popupElement = ref?.current;
37244
- if (popupElement) {
37245
- popupsMountingContentForOpen.add(popupElement);
37246
- }
37247
- try {
37248
- flushSyncRendering(() => {
37249
- setContentMounted(true);
37250
- });
37251
- } finally {
37252
- if (popupElement) {
37253
- popupsMountingContentForOpen.delete(popupElement);
37254
- }
37255
- }
37256
- };
37617
+ // What was last asked for, ahead of the render that performs it: an open
37618
+ // landing in between (a press-warmed content given up just before the open
37619
+ // that wanted it) must still find it has to build.
37620
+ const contentMountedRef = useRef(contentMounted);
37621
+ const setContentMounted = (value) => {
37622
+ contentMountedRef.current = value;
37623
+ setContentMountedState(value);
37624
+ };
37625
+ openController.mountContent = () => {
37626
+ if (contentMountedRef.current) {
37627
+ return;
37628
+ }
37629
+ const popupElement = ref?.current;
37630
+ if (popupElement) {
37631
+ popupsMountingContentForOpen.add(popupElement);
37632
+ }
37633
+ try {
37634
+ flushSyncRendering(() => {
37635
+ setContentMounted(true);
37636
+ });
37637
+ } finally {
37638
+ if (popupElement) {
37639
+ popupsMountingContentForOpen.delete(popupElement);
37640
+ }
37641
+ }
37642
+ };
37257
37643
  openController.unmountContent =
37258
37644
  mount === "while-opened"
37259
37645
  ? () => {
@@ -37291,41 +37677,140 @@ const usePopupContentMount = (
37291
37677
  cancelIdle(idleId);
37292
37678
  };
37293
37679
  }, [mount, contentMounted]);
37294
- // Warm on intent (see the top comment; "while-opened" is excluded there).
37295
- // The anchor accepts the same shapes Popover resolves at open time — a
37296
- // string id, a ref, an element — but is resolved here at effect time: an id
37297
- // that matches nothing yet simply doesn't warm, the open still mounts the
37298
- // content like it always does.
37680
+ // Warm on intent (see the top comment).
37299
37681
  useEffect(() => {
37300
37682
  if (contentMounted || !anchor || mount === "while-opened") {
37301
37683
  return undefined;
37302
37684
  }
37303
- const anchorElement =
37304
- typeof anchor === "string"
37305
- ? document.getElementById(anchor)
37306
- : // A ref is unwrapped even when it holds nothing: an expandable with
37307
- // no UI part hands an empty ref over, and the ref object itself is
37308
- // truthy — it would reach addEventListener below and throw.
37309
- "current" in anchor
37310
- ? anchor.current
37311
- : anchor;
37685
+ const anchorElement = resolveAnchorElement(anchor);
37312
37686
  if (!anchorElement) {
37313
37687
  return undefined;
37314
37688
  }
37689
+ let cancelWarm = null;
37315
37690
  const warm = () => {
37316
- setContentMounted(true);
37691
+ if (cancelWarm) {
37692
+ return;
37693
+ }
37694
+ cancelWarm = requestFrameAfterNext(() => {
37695
+ setContentMounted(true);
37696
+ });
37317
37697
  };
37318
37698
  anchorElement.addEventListener("pointerenter", warm);
37319
37699
  anchorElement.addEventListener("focusin", warm);
37320
37700
  return () => {
37701
+ cancelWarm?.();
37321
37702
  anchorElement.removeEventListener("pointerenter", warm);
37322
37703
  anchorElement.removeEventListener("focusin", warm);
37323
37704
  };
37324
37705
  }, [contentMounted, anchor, mount]);
37706
+ // Warm on an opening press, for "while-opened" (see the top comment).
37707
+ // Listening for as long as the popup is there, not only while the content is
37708
+ // unmounted: the press that throws a warmed content away can be a new
37709
+ // opening press of this same popup, and it must warm again.
37710
+ useEffect(() => {
37711
+ if (mount !== "while-opened") {
37712
+ return undefined;
37713
+ }
37714
+ const element = openController.getElement?.();
37715
+ if (!element) {
37716
+ return undefined;
37717
+ }
37718
+ let stopPressWarm = null;
37719
+ const onOpeningPress = () => {
37720
+ if (contentMountedRef.current || openController.onOpen) {
37721
+ return;
37722
+ }
37723
+ const cancelBuild = requestFrameAfterNext(() => {
37724
+ setContentMounted(true);
37725
+ });
37726
+ const stopWatching = watchPressEnd(() => {
37727
+ stopPressWarm = null;
37728
+ cancelBuild();
37729
+ if (!openController.opened) {
37730
+ setContentMounted(false);
37731
+ }
37732
+ });
37733
+ stopPressWarm = () => {
37734
+ cancelBuild();
37735
+ stopWatching();
37736
+ };
37737
+ };
37738
+ element.addEventListener("navi_open_press", onOpeningPress);
37739
+ return () => {
37740
+ stopPressWarm?.();
37741
+ element.removeEventListener("navi_open_press", onOpeningPress);
37742
+ };
37743
+ }, [mount]);
37325
37744
 
37326
37745
  return contentMounted;
37327
37746
  };
37328
37747
 
37748
+ // The anchor accepts the same shapes Popover resolves at open time — a string
37749
+ // id, a ref, an element — but is resolved at effect time: an id that matches
37750
+ // nothing yet simply doesn't warm, the open still mounts the content.
37751
+ const resolveAnchorElement = (anchor) => {
37752
+ if (typeof anchor === "string") {
37753
+ return document.getElementById(anchor);
37754
+ }
37755
+ // A ref is unwrapped even when it holds nothing: an expandable with no UI
37756
+ // part hands an empty ref over, and the ref object itself is truthy — it
37757
+ // would reach addEventListener and throw.
37758
+ if ("current" in anchor) {
37759
+ return anchor.current;
37760
+ }
37761
+ return anchor;
37762
+ };
37763
+
37764
+ // The next frame paints what the input just changed (the pressed trigger); the
37765
+ // callback runs at the start of the one after.
37766
+ const requestFrameAfterNext = (callback) => {
37767
+ let frame = requestAnimationFrame(() => {
37768
+ frame = requestAnimationFrame(callback);
37769
+ });
37770
+ return () => {
37771
+ cancelAnimationFrame(frame);
37772
+ };
37773
+ };
37774
+
37775
+ // How long the click of a released press may take to arrive. Nothing tells a
37776
+ // page that a release brings no click — a press that became a drag has its
37777
+ // click swallowed (suppressClickAfterGesture in @jsenv/dom) — so past this the
37778
+ // press is taken to have ended without one. Longer than the 300ms a browser
37779
+ // still waits before the click of a tap on a page not sized for phones.
37780
+ const PRESS_CLICK_WAIT_MS = 500;
37781
+
37782
+ // Installed during the press's own pointerdown: the capture listeners below
37783
+ // only hear the presses after it.
37784
+ const watchPressEnd = (onEnd) => {
37785
+ let timeout;
37786
+ const stop = () => {
37787
+ clearTimeout(timeout);
37788
+ document.removeEventListener("click", onClick);
37789
+ document.removeEventListener("pointerup", onPointerUp, { capture: true });
37790
+ document.removeEventListener("pointercancel", end, { capture: true });
37791
+ document.removeEventListener("pointerdown", end, { capture: true });
37792
+ };
37793
+ const end = () => {
37794
+ stop();
37795
+ onEnd();
37796
+ };
37797
+ // Bubble phase, on the document: every click handler that could open the
37798
+ // popup has run by then.
37799
+ const onClick = (clickEvent) => {
37800
+ if (isPressDrivenClick(clickEvent)) {
37801
+ end();
37802
+ }
37803
+ };
37804
+ const onPointerUp = () => {
37805
+ timeout = setTimeout(end, PRESS_CLICK_WAIT_MS);
37806
+ };
37807
+ document.addEventListener("click", onClick);
37808
+ document.addEventListener("pointerup", onPointerUp, { capture: true });
37809
+ document.addEventListener("pointercancel", end, { capture: true });
37810
+ document.addEventListener("pointerdown", end, { capture: true });
37811
+ return stop;
37812
+ };
37813
+
37329
37814
  /**
37330
37815
  * A variant of useLayoutEffect that accounts for ancestor <dialog>/<details>
37331
37816
  * or popover visibility.
@@ -38381,6 +38866,9 @@ const useUIStateController = (
38381
38866
  // The suggestion this control started on — what tells a field showing
38382
38867
  // its default from one carrying an answer (see isUIStateHeld).
38383
38868
  defaultValue: controlInfo.defaultValue,
38869
+ // The default last put on screen, which lags `defaultValue` while an
38870
+ // edit holds a newer one back (see followDefaultValue).
38871
+ defaultValueTaken: controlInfo.defaultValue,
38384
38872
 
38385
38873
  facadeChild: null,
38386
38874
  // Set for the duration of one interaction by whatever wants the
@@ -38482,6 +38970,18 @@ const useUIStateController = (
38482
38970
  e.currentTarget || controller.ref.current,
38483
38971
  );
38484
38972
  const currentUIState = controller.uiState;
38973
+ // What a control is STARTED on is accepted, as its own defaultValue
38974
+ // would be: a value placed on it as it registers, or what a picker
38975
+ // takes up from its popup while it holds nothing of its own. A
38976
+ // rollback goes back there, and a control left on it holds no edit.
38977
+ if (!controller.hasStateProp) {
38978
+ if (
38979
+ e.type === "initial_state_push" ||
38980
+ (e.type === "facade_child_mount_sync" && !controller.holdsEdit())
38981
+ ) {
38982
+ controller.state = newUIState;
38983
+ }
38984
+ }
38485
38985
  const stateIsTheSame = compareTwoJsValues(newUIState, currentUIState);
38486
38986
  if (stateIsTheSame) {
38487
38987
  if (controlType === "button" || controlType === "link") {
@@ -38647,7 +39147,12 @@ const useUIStateController = (
38647
39147
  }
38648
39148
  // initial_state_push is pure initialization (equivalent to defaultValue on the
38649
39149
  // child itself): skip uiAction entirely so no side effects fire on mount.
38650
- if (e.type !== "initial_state_push") {
39150
+ // A value the outside moved since (see outside_value_follow) is the
39151
+ // same initialization, later.
39152
+ if (
39153
+ e.type !== "initial_state_push" &&
39154
+ e.type !== "outside_value_follow"
39155
+ ) {
38651
39156
  // Still fire uiAction so external listeners (e.g. signals) stay in
38652
39157
  // sync, but do NOT fire the command and do NOT notify the parent —
38653
39158
  // both would cause an infinite loop when a parent cascades state
@@ -38701,6 +39206,19 @@ const useUIStateController = (
38701
39206
  stateChanged: true,
38702
39207
  });
38703
39208
  }
39209
+ if (
39210
+ e.type === "outside_value_follow" ||
39211
+ (e.type === "facade_child_mount_sync" && isOutsideValueFollow(e))
39212
+ ) {
39213
+ // Heard the way a control mounting on that value would be:
39214
+ // silently, nobody acted. But heard, all the way up — a picker
39215
+ // whose popup followed tells its own group in turn — or what
39216
+ // the groups send is not what is on screen.
39217
+ s.parentUIStateController?.onChildUIAction(controller, e, {
39218
+ stateChanged: true,
39219
+ silent: true,
39220
+ });
39221
+ }
38704
39222
  return true;
38705
39223
  }
38706
39224
  s.parentUIStateController?.onChildUIAction(controller, e, {
@@ -38796,6 +39314,41 @@ const useUIStateController = (
38796
39314
  // pick — the signal would hand it straight back on the next render,
38797
39315
  // and the refused value would win over the rollback.
38798
39316
  writeBoundSignal(controller.state);
39317
+ controller.followDefaultValue();
39318
+ },
39319
+ // Something typed, picked or toggled since the value the outside last
39320
+ // gave or accepted.
39321
+ holdsEdit: () => {
39322
+ return !compareTwoJsValues(controller.uiState, controller.state);
39323
+ },
39324
+ // An uncontrolled control starts on its defaultValue, and a new one is
39325
+ // what it would start on if it were mounted again — a record saved
39326
+ // and handed back normalized, refreshed from elsewhere. It takes it
39327
+ // while it holds no edit. An edit is never undone; the default waits
39328
+ // for it to be accepted or rolled back.
39329
+ followDefaultValue: () => {
39330
+ // A bound signal carries its own default, followed through
39331
+ // stateFromSignal; a proxy shows the control it stands for.
39332
+ if (
39333
+ controller.hasStateProp ||
39334
+ controller.isProxy ||
39335
+ controller.props.signal
39336
+ ) {
39337
+ return;
39338
+ }
39339
+ const { defaultValue } = controller;
39340
+ if (compareTwoJsValues(defaultValue, controller.defaultValueTaken)) {
39341
+ return;
39342
+ }
39343
+ if (controller.holdsEdit()) {
39344
+ return;
39345
+ }
39346
+ controller.defaultValueTaken = defaultValue;
39347
+ controller.state = defaultValue;
39348
+ controller.setUIState(
39349
+ defaultValue,
39350
+ new CustomEvent("outside_value_follow", { detail: {} }),
39351
+ );
38799
39352
  },
38800
39353
  // Read by the callout manager when it has nowhere else to point.
38801
39354
  getCalloutAnchorElement: (event) =>
@@ -38809,7 +39362,12 @@ const useUIStateController = (
38809
39362
  if (controller.hasStateProp || controller.queuedActionAllowedEvent) {
38810
39363
  return;
38811
39364
  }
39365
+ // A picker told yes is told for what its popup shows too, the way a
39366
+ // group tells its children. First, so that a default one of them
39367
+ // takes on the way is part of what this control accepts below.
39368
+ controller.facadeChild?.acknowledgeUIState();
38812
39369
  controller.state = controller.uiState;
39370
+ controller.followDefaultValue();
38813
39371
  },
38814
39372
  onActionEnd: (e) => {
38815
39373
  acknowledgeOwnAction(controller);
@@ -38964,6 +39522,7 @@ const useUIStateController = (
38964
39522
  }
38965
39523
  }
38966
39524
  }
39525
+ controller.followDefaultValue();
38967
39526
  }
38968
39527
  return liveValues();
38969
39528
  },
@@ -39544,7 +40103,12 @@ const useUIGroupStateController = (
39544
40103
  // deferred alongside a mount sync stays a real change.
39545
40104
  const pendingChange = pendingChangeRef.current;
39546
40105
  pendingChangeRef.current = {
39547
- e,
40106
+ // A default followed is not replaced by a mount deferred beside
40107
+ // it: the event is what lets the sync below read the children.
40108
+ e:
40109
+ pendingChange && isOutsideValueFollow(pendingChange.e)
40110
+ ? pendingChange.e
40111
+ : e,
39548
40112
  notifyExternal:
39549
40113
  pendingChange?.notifyExternal === true ? true : notifyExternal,
39550
40114
  actingChild,
@@ -39552,6 +40116,13 @@ const useUIGroupStateController = (
39552
40116
  return;
39553
40117
  }
39554
40118
  const { controller } = s;
40119
+ if (isOutsideValueFollow(e)) {
40120
+ // Not a partial reading: the child was there already and its value
40121
+ // moved, told by the outside. The group is worth what its children
40122
+ // show, whatever it was handed before — kept, the value it sends
40123
+ // would be one the screen no longer shows.
40124
+ controller.stateGivenFromAbove = false;
40125
+ }
39555
40126
  // A child mounting or unmounting is not somebody answering: while the
39556
40127
  // children of a group are still arriving, their aggregate is a partial
39557
40128
  // reading, and taking it for the truth is how the value the group was
@@ -39730,6 +40301,9 @@ const useUIGroupStateController = (
39730
40301
  name,
39731
40302
  value,
39732
40303
  defaultValue,
40304
+ // Same as the leaf's (see useUIStateController): the default last
40305
+ // placed on the children, behind `defaultValue` while an edit waits.
40306
+ defaultValueTaken: defaultValue,
39733
40307
  hasValueProp,
39734
40308
  hasDefaultValueProp,
39735
40309
  props,
@@ -40010,6 +40584,7 @@ const useUIGroupStateController = (
40010
40584
  }
40011
40585
  }
40012
40586
  onChange(e, { notifyExternal: true });
40587
+ controller.followDefaultValue();
40013
40588
  },
40014
40589
  clearUIState: (e) => {
40015
40590
  const ev = new CustomEvent("propagate_down_clear_ui_state", {
@@ -40038,6 +40613,46 @@ const useUIGroupStateController = (
40038
40613
  c.acknowledgeUIState();
40039
40614
  }
40040
40615
  }
40616
+ controller.followDefaultValue();
40617
+ },
40618
+ // A group holds no value of its own to have edited: it holds an edit
40619
+ // when one of its children does.
40620
+ holdsEdit: () => {
40621
+ return childUIStateControllerArray.some(
40622
+ (c) => shouldPropagateStateToChild(c) && c.holdsEdit(),
40623
+ );
40624
+ },
40625
+ // The leaf's rule (see useUIStateController), for a group given a
40626
+ // plain `defaultValue`: a new one is placed on the children the way
40627
+ // the first one was as they registered, while none of them holds an
40628
+ // edit. A signal's own default is followed by the update below.
40629
+ followDefaultValue: () => {
40630
+ if (controller.hasValueProp || controller.props.signal) {
40631
+ return;
40632
+ }
40633
+ const { defaultValue } = controller;
40634
+ if (compareTwoJsValues(defaultValue, controller.defaultValueTaken)) {
40635
+ return;
40636
+ }
40637
+ if (controller.holdsEdit()) {
40638
+ return;
40639
+ }
40640
+ controller.defaultValueTaken = defaultValue;
40641
+ const followEvent = new CustomEvent("outside_value_follow", {
40642
+ detail: {},
40643
+ });
40644
+ const placeEvent = new CustomEvent("initial_state_push", {
40645
+ detail: {},
40646
+ });
40647
+ chainEvent(placeEvent, followEvent);
40648
+ controller.setUIState(
40649
+ defaultValue === undefined ? fallbackState : defaultValue,
40650
+ placeEvent,
40651
+ );
40652
+ s.parentUIStateController?.onChildUIAction(controller, followEvent, {
40653
+ stateChanged: true,
40654
+ silent: true,
40655
+ });
40041
40656
  },
40042
40657
  onActionEnd: (e) => {
40043
40658
  acknowledgeOwnAction(controller);
@@ -40160,6 +40775,7 @@ const useUIGroupStateController = (
40160
40775
  // former value back over it on the next child interaction.
40161
40776
  placeChildrenFrom(defaultValue);
40162
40777
  }
40778
+ controller.followDefaultValue();
40163
40779
 
40164
40780
  return liveValues();
40165
40781
  },
@@ -40384,7 +41000,11 @@ const useUIFacadeStateController = (props, realUIStateController) => {
40384
41000
  if (child !== firstChildControllerRef.current) {
40385
41001
  return;
40386
41002
  }
40387
- if (silent && uiStateHoldsNothing(child.uiState)) {
41003
+ if (
41004
+ silent &&
41005
+ !isOutsideValueFollow(e) &&
41006
+ uiStateHoldsNothing(child.uiState)
41007
+ ) {
40388
41008
  // A silent sync means the child's own structure changed (children
40389
41009
  // mounted/unmounted), not that the user acted. A child that ends up
40390
41010
  // with no value there is one that currently *cannot* express one —
@@ -40395,7 +41015,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
40395
41015
  // still nothing to adopt: the sync would only respell one nothing
40396
41016
  // as another (an array picker's [] becoming undefined, say) and
40397
41017
  // hand that to uiAction — an empty array picker opened its popup
40398
- // and told its owner the value changed.
41018
+ // and told its owner the value changed. A default followed down to
41019
+ // nothing is not that: the outside emptied a value the child held.
40399
41020
  return;
40400
41021
  }
40401
41022
  updatingRef.current = true;
@@ -40459,6 +41080,69 @@ const useUIFacadeStateController = (props, realUIStateController) => {
40459
41080
  return scope.controller;
40460
41081
  };
40461
41082
 
41083
+ /**
41084
+ * What every control of a picker — the picker and the controls in its popup —
41085
+ * has been accepted on as the popup opens. Read back by followOutsideSinceOpen
41086
+ * when the popup is cancelled.
41087
+ */
41088
+ const readStatesAtOpen = (pickerController) => {
41089
+ const statesAtOpen = new Map();
41090
+ visitControllers(pickerController, (controller) => {
41091
+ if (Object.hasOwn(controller, "state")) {
41092
+ statesAtOpen.set(controller, controller.state);
41093
+ }
41094
+ });
41095
+ return statesAtOpen;
41096
+ };
41097
+
41098
+ /**
41099
+ * A cancel puts back the value the picker held at open, which takes back
41100
+ * everything that moved since — and not only what the user moved: a record
41101
+ * refreshed while the popup was open, a default that arrived under a field
41102
+ * holding an edit. Those were not the user's to take back. Once the value at
41103
+ * open is back, each control the outside moved since shows what the outside
41104
+ * holds again, and a default left waiting on an edit is taken there.
41105
+ *
41106
+ * Children before the control around them: a picker given its value by its
41107
+ * owner has the last word over the controls in its popup.
41108
+ */
41109
+ const followOutsideSinceOpen = (pickerController, statesAtOpen) => {
41110
+ visitControllers(
41111
+ pickerController,
41112
+ (controller) => {
41113
+ if (
41114
+ statesAtOpen.has(controller) &&
41115
+ !compareTwoJsValues(controller.state, statesAtOpen.get(controller)) &&
41116
+ controller.holdsEdit()
41117
+ ) {
41118
+ controller.setUIState(
41119
+ controller.state,
41120
+ new CustomEvent("outside_value_follow", { detail: {} }),
41121
+ );
41122
+ }
41123
+ controller.followDefaultValue();
41124
+ },
41125
+ { childrenFirst: true },
41126
+ );
41127
+ };
41128
+
41129
+ // A picker reaches the controls of its popup through its facade child, a group
41130
+ // through the children it registered.
41131
+ const visitControllers = (controller, visit, { childrenFirst } = {}) => {
41132
+ if (!childrenFirst) {
41133
+ visit(controller);
41134
+ }
41135
+ const children = controller.facadeChild
41136
+ ? [controller.facadeChild]
41137
+ : controller.getChildControllers?.() || [];
41138
+ for (const child of children) {
41139
+ visitControllers(child, visit, { childrenFirst });
41140
+ }
41141
+ if (childrenFirst) {
41142
+ visit(controller);
41143
+ }
41144
+ };
41145
+
40462
41146
  const describePicker = (props) =>
40463
41147
  `<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
40464
41148
 
@@ -40523,10 +41207,18 @@ const INTERNAL_EVENT_SET = new Set([
40523
41207
  // no action of the control's own — but what it holds really did move, so
40524
41208
  // uiAction, the bound signal and the parent notification below all happen.
40525
41209
  "auto_fix",
41210
+ // A control put on a value the outside moved while nobody was editing it: a
41211
+ // new defaultValue (see followDefaultValue), or what a cancel puts back
41212
+ // (see followOutsideSinceOpen). Initialization arriving after mount, so
41213
+ // nothing fires, and the groups above hear it silently.
41214
+ "outside_value_follow",
40526
41215
  ]);
40527
41216
  const isInternalEvent = (e) => {
40528
41217
  return INTERNAL_EVENT_SET.has(e.type);
40529
41218
  };
41219
+ const isOutsideValueFollow = (e) => {
41220
+ return Boolean(findEvent(e, "outside_value_follow"));
41221
+ };
40530
41222
 
40531
41223
  /**
40532
41224
  * A value handed DOWN to a control by whoever owns it: a picker filling its
@@ -45965,6 +46657,35 @@ const usePreloadOnIntent = (ref, href, prefetch = true) => {
45965
46657
  }, [ref, href, prefetch]);
45966
46658
  };
45967
46659
 
46660
+ /**
46661
+ * What a popup's opening will read, asked for when the press on the element
46662
+ * whose command opens it starts (see announceOpeningPress): the popup, the
46663
+ * value it opens on and so the address it will write are all known before
46664
+ * the release.
46665
+ */
46666
+ const usePreloadOpeningOnPress = (ref, command, prefetch = true) => {
46667
+ useEffect(() => {
46668
+ if (!prefetch || !command) {
46669
+ return undefined;
46670
+ }
46671
+ const element = ref.current;
46672
+ if (!element) {
46673
+ return undefined;
46674
+ }
46675
+ const onPointerDown = (pointerdownEvent) => {
46676
+ // A right click opens a menu, never the popup.
46677
+ if (pointerdownEvent.button !== 0) {
46678
+ return;
46679
+ }
46680
+ announceOpeningPress(element, command);
46681
+ };
46682
+ element.addEventListener("pointerdown", onPointerDown);
46683
+ return () => {
46684
+ element.removeEventListener("pointerdown", onPointerDown);
46685
+ };
46686
+ }, [ref, command, prefetch]);
46687
+ };
46688
+
45968
46689
  /**
45969
46690
  * Hook that reactively checks if a URL is visited.
45970
46691
  * Re-renders when the visited URL set changes.
@@ -49965,7 +50686,8 @@ const useExpandableContext = partName => {
49965
50686
  * content — and rebuilds it from scratch on every expansion. Under the
49966
50687
  * other three, intent on the UI part (pointer entering it, focus landing in
49967
50688
  * it) builds the content ahead of the click; `"while-opened"` content is
49968
- * never warmed, it is built at the open.
50689
+ * built by a press on it only, and thrown away if that press does not
50690
+ * expand it.
49969
50691
  */
49970
50692
  const Expandable = props => {
49971
50693
  import.meta.css = [css$R, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -50044,8 +50766,14 @@ const Expandable = props => {
50044
50766
  // before the click ever fires — so what held the focus has to be remembered
50045
50767
  // at pointerdown time.
50046
50768
  const focusedAtPointerDownRef = useRef(null);
50047
- const onUIPointerDown = () => {
50769
+ const onUIPointerDown = pointerdownEvent => {
50048
50770
  focusedAtPointerDownRef.current = document.activeElement;
50771
+ // The start of an expansion, announced as a --navi-open button announces
50772
+ // its own (see announceOpeningPress in commands.js) — unless aimed at a
50773
+ // control inside the UI part, whose click does not toggle (see onUIClick).
50774
+ if (pointerdownEvent.button === 0 && !openController.opened && !isAimedAtControlInsideUI(pointerdownEvent.target)) {
50775
+ dispatchCustomEvent(rootRef.current, "navi_open_press");
50776
+ }
50049
50777
  };
50050
50778
 
50051
50779
  // The content keeps its final size while the track animates (see the top
@@ -50343,17 +51071,18 @@ const Expandable = props => {
50343
51071
  if (clickEvent.defaultPrevented) {
50344
51072
  return;
50345
51073
  }
50346
- const {
50347
- target
50348
- } = clickEvent;
50349
- if (target.nodeType === 1) {
50350
- const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
50351
- if (interactiveElement && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement)) {
50352
- return;
50353
- }
51074
+ if (isAimedAtControlInsideUI(clickEvent.target)) {
51075
+ return;
50354
51076
  }
50355
51077
  toggle(clickEvent);
50356
51078
  };
51079
+ const isAimedAtControlInsideUI = target => {
51080
+ if (target.nodeType !== 1) {
51081
+ return false;
51082
+ }
51083
+ const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
51084
+ return Boolean(interactiveElement) && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement);
51085
+ };
50357
51086
 
50358
51087
  // Space/Enter on the UI part itself (role button) — a key pressed on a
50359
51088
  // control inside it belongs to that control.
@@ -51277,6 +52006,9 @@ const ButtonUI = props => {
51277
52006
  // Nothing to prefetch on the way to another document: the routes do not
51278
52007
  // lead there.
51279
52008
  usePreloadOnIntent(ref, href, isDocument ? false : prefetch);
52009
+ // A press the button refuses opens nothing: nothing to ask ahead for.
52010
+ const refusesPress = basePseudoState[":read-only"] || basePseudoState[":disabled"] || loading;
52011
+ usePreloadOpeningOnPress(ref, props.command, prefetch && !refusesPress);
51280
52012
  useAccentColorAttributes(ref, null, {
51281
52013
  elementSelector: visualSelector
51282
52014
  });
@@ -51768,8 +52500,11 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
51768
52500
  * pointer or the focus arrives, ahead of the press (see
51769
52501
  * docs/dynamic_import.md): the route actions that ask nothing of the
51770
52502
  * address — a page's code, a read without params — are prerun; one keyed by
51771
- * a param waits for the arrival. `false` for a destination not worth
51772
- * fetching on a hover.
52503
+ * a param waits for the arrival. With a `--navi-open`/`--navi-toggle`
52504
+ * command instead, the press — not the hover: what it asks is keyed by the
52505
+ * popup — preruns the route actions whose params the popup's opening
52506
+ * changes, read as if its `signal` held its `value`. `false` for a
52507
+ * destination not worth fetching ahead.
51773
52508
  * @param {boolean} [pressableDuringRouteTransition] Keep answering presses
51774
52509
  * while a route transition plays: what a movement photographs goes deaf to
51775
52510
  * the pointer for its whole length, and the door that opened the page — a
@@ -58649,6 +59384,52 @@ const SlideContainer = ({
58649
59384
  drag.areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
58650
59385
  paintDrag();
58651
59386
  },
59387
+ // The slide being brought in walked whole and the hand still going, or a
59388
+ // travel caught on its way thrown on: either way the hand asks for the
59389
+ // slide past it, and the gesture moves on to it. Nothing on screen moves
59390
+ // for that — the slide reached becomes the one dragged, the one the hand
59391
+ // came from stays next to it for a hand that turns around, and the slide
59392
+ // past it is put on the other side.
59393
+ onEdge: ({
59394
+ axis,
59395
+ sign
59396
+ }) => {
59397
+ const reached = sign > 0 ? drag.areaBack : drag.areaOn;
59398
+ const {
59399
+ slideElements,
59400
+ placeOf
59401
+ } = readMap();
59402
+ const reachedElement = reached && slideElements.find(slideElement => readArea(slideElement) === reached);
59403
+ if (!reachedElement) {
59404
+ return false;
59405
+ }
59406
+ // The gate goToArea reads at the release, asked of the slide the hand
59407
+ // is now in: a slide holding on to the user is not walked through.
59408
+ const holds = reachedElement.hasAttribute(sign > 0 ? "data-prevent-nav-previous" : "data-prevent-nav-next");
59409
+ const past = holds ? undefined : axis === "x" ? areaTowards(-sign, 0, reached) : areaTowards(0, -sign, reached);
59410
+ if (!past) {
59411
+ return false;
59412
+ }
59413
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
59414
+ const basePlace = stageRef.current?.placeByArea.get(reached) || placeOf.get(reached) || {
59415
+ x: 0,
59416
+ y: 0
59417
+ };
59418
+ drag.areaBack = sign > 0 ? past : drag.area;
59419
+ drag.areaOn = sign > 0 ? drag.area : past;
59420
+ drag.area = reached;
59421
+ drag.basePlace = basePlace;
59422
+ drag.baseOffset = {
59423
+ x: -basePlace.x * drag.box.width,
59424
+ y: -basePlace.y * drag.box.height
59425
+ };
59426
+ stageDrag(drag, readArea(currentElement));
59427
+ return {
59428
+ size: axis === "x" ? drag.box.width : drag.box.height,
59429
+ travelBack: Boolean(drag.areaBack),
59430
+ travelOn: Boolean(drag.areaOn)
59431
+ };
59432
+ },
58652
59433
  onEnd: ({
58653
59434
  axis,
58654
59435
  sign,
@@ -62537,9 +63318,11 @@ const css$E = /* css */`
62537
63318
  * the cost on the critical render. `"while-opened"` throws them away once
62538
63319
  * the popup has finished closing, for content whose fresh state is its
62539
63320
  * initial state: an uncontrolled field seeded from a `defaultValue` that
62540
- * changed while the popup was closed. Whatever the value, intent on the
62541
- * anchor (pointer entering it, focus landing in it) builds the content
62542
- * ahead of the click.
63321
+ * changed while the popup was closed. Intent on the anchor (pointer
63322
+ * entering it, focus landing in it) builds the content ahead of the click;
63323
+ * under `"while-opened"`, only a press on what opens the popup does (a
63324
+ * `--navi-open` button, a picker's trigger), never with an `onOpen`, and a
63325
+ * press that does not open the popup throws it away.
62543
63326
  * @param {import("ignore:preact").ComponentChildren} props.children
62544
63327
  */
62545
63328
  const Dialog = props => {
@@ -64217,9 +65000,11 @@ const css$D = /* css */`
64217
65000
  * the cost on the critical render. `"while-opened"` throws them away once
64218
65001
  * the popup has finished closing, for content whose fresh state is its
64219
65002
  * initial state: an uncontrolled field seeded from a `defaultValue` that
64220
- * changed while the popup was closed. Whatever the value, intent on the
64221
- * anchor (pointer entering it, focus landing in it) builds the content
64222
- * ahead of the click.
65003
+ * changed while the popup was closed. Intent on the anchor (pointer
65004
+ * entering it, focus landing in it) builds the content ahead of the click;
65005
+ * under `"while-opened"`, only a press on what opens the popup does (a
65006
+ * `--navi-open` button, a picker's trigger), never with an `onOpen`, and a
65007
+ * press that does not open the popup throws it away.
64223
65008
  * @param {import("ignore:preact").ComponentChildren} props.children
64224
65009
  */
64225
65010
  const Popover = props => {
@@ -65882,7 +66667,10 @@ const PickerCustom = props => {
65882
66667
  // a defaultValue holds nothing, so closing on it untouched IS the answer
65883
66668
  // ("yes, 2h15") — the same rule Form applies to an untouched field (see
65884
66669
  // isUIStateHeld). Read at open, before anything inside can change it.
65885
- const heldAtOpen = isUIStateHeld(getPickerInput(ref.current)?.__uiStateController__);
66670
+ const pickerController = getPickerInput(ref.current)?.__uiStateController__;
66671
+ const heldAtOpen = isUIStateHeld(pickerController);
66672
+ // What a cancel must not take back: see followOutsideSinceOpen.
66673
+ const statesAtOpen = pickerController ? readStatesAtOpen(pickerController) : null;
65886
66674
  onOpen?.(openEvent);
65887
66675
  return {
65888
66676
  onRequestClose: requestCloseEvent => {
@@ -65950,6 +66738,9 @@ const PickerCustom = props => {
65950
66738
  });
65951
66739
  chainEvent(rollbackEvent, closeEvent);
65952
66740
  inputEl.__uiStateController__.setUIState(valueAtOpen, rollbackEvent);
66741
+ if (statesAtOpen) {
66742
+ followOutsideSinceOpen(inputEl.__uiStateController__, statesAtOpen);
66743
+ }
65953
66744
  } else if (!heldAtOpen) {
65954
66745
  // Confirmed a suggestion: nothing changed, so nothing has told the
65955
66746
  // control's own bound signal / uiAction that this is now the
@@ -66030,6 +66821,11 @@ const PickerCustom = props => {
66030
66821
  "onnavi_request_open": e => {
66031
66822
  dispatchCustomEvent(popupRef.current, "navi_request_open", e.detail);
66032
66823
  },
66824
+ // And the press announcing that request (see announceOpeningPress in
66825
+ // commands.js), for the popup's content to be built on it.
66826
+ "onnavi_open_press": () => {
66827
+ dispatchCustomEvent(popupRef.current, "navi_open_press");
66828
+ },
66033
66829
  "onnavi_request_close": e => {
66034
66830
  const closing = dispatchCustomEvent(popupRef.current, "navi_request_close", e.detail);
66035
66831
  if (!closing) {
@@ -66224,6 +67020,25 @@ const PickerCustom = props => {
66224
67020
  // below), and the keyboard keeps its own ways in (the shortcuts above).
66225
67021
  const openOnList = Array.isArray(openOn) ? openOn : [openOn];
66226
67022
  const opensOnPress = openOnList.includes("press");
67023
+ // A press on the trigger of a picker a press opens is announced as it
67024
+ // starts, as a --navi-open button announces its own (see
67025
+ // announceOpeningPress in commands.js). Only a press that can open it: a
67026
+ // picker opened by a hold says nothing on a tap, nor does one refusing
67027
+ // to open (read-only with openWhileReadOnly={false}, busy). The
67028
+ // interactivity is asked alone, not the whole gate: a drag source
67029
+ // cancels the pointerdown, and the click that opens still comes.
67030
+ if (opensOnPress) {
67031
+ pickerProps.onPointerDown = pointerdownEvent => {
67032
+ props.onPointerDown?.(pointerdownEvent);
67033
+ const pickerEl = ref.current;
67034
+ if (pointerdownEvent.button !== 0 || openController.opened || isWithinPickerContent(pointerdownEvent.target) || !allowsInteraction(pickerEl, {
67035
+ intent: "read"
67036
+ })) {
67037
+ return;
67038
+ }
67039
+ dispatchCustomEvent(pickerEl, "navi_open_press");
67040
+ };
67041
+ }
66227
67042
  let interactions = props.interactions;
66228
67043
  if (!opensOnPress) {
66229
67044
  interactions = {
@@ -78354,6 +79169,9 @@ const css$n = /* css */`.navi_split_button {
78354
79169
  * defaultValue?: any,
78355
79170
  * label?: import("ignore:preact").ComponentChildren,
78356
79171
  * action?: (value: any, event: Event) => void | Promise<void>,
79172
+ * command?: string,
79173
+ * commandFor?: string,
79174
+ * prefetch?: boolean,
78357
79175
  * onValueChange?: (value: any, event: Event) => void,
78358
79176
  * chooseEffect?: "select" | "run",
78359
79177
  * menuLabel?: string,
@@ -78407,6 +79225,12 @@ const css$n = /* css */`.navi_split_button {
78407
79225
  * entry given. Pressed on the button it is the entry the button stands for;
78408
79226
  * with `chooseEffect="run"` it is also the entry just chosen. Awaited: the
78409
79227
  * whole split button is busy until it settles.
79228
+ * @param {string} [command] What a press on the button does once its `action`
79229
+ * is done, as on a `<Button>` (with `commandFor`, and `command-value` when
79230
+ * what the command is about is not the entry the button stands for). A
79231
+ * split button whose button opens a popup says it here rather than from its
79232
+ * `action`: the press is then read as an opening before it runs, and asks
79233
+ * ahead for what the popup's opening reads (see `prefetch` on Button).
78410
79234
  * @param {"select"|"run"} [chooseEffect="select"] What choosing an entry does.
78411
79235
  * "select" hands the button that entry, to be run by a press on it — for
78412
79236
  * something one does again and again, where the last choice is the likely
@@ -78448,6 +79272,10 @@ const SplitButton = props => {
78448
79272
  defaultValue,
78449
79273
  label,
78450
79274
  action,
79275
+ command,
79276
+ commandFor,
79277
+ "command-value": commandValue,
79278
+ prefetch,
78451
79279
  onValueChange,
78452
79280
  chooseEffect = "select",
78453
79281
  menuLabel = naviI18n("button.more_actions"),
@@ -78541,6 +79369,10 @@ const SplitButton = props => {
78541
79369
  id: idResolved,
78542
79370
  value: valueResolved,
78543
79371
  action: actionResolved,
79372
+ command: command,
79373
+ commandFor: commandFor,
79374
+ "command-value": commandValue,
79375
+ prefetch: prefetch,
78544
79376
  ...halfProps,
78545
79377
  children: label === undefined ? optionShown?.label : label
78546
79378
  }), jsxs("div", {