@jsenv/navi 0.29.399 → 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 },
@@ -28293,112 +28625,6 @@ const dropGeneratedIdKeys = (state) => {
28293
28625
  return stateWithoutGeneratedIdKeys;
28294
28626
  };
28295
28627
 
28296
- /**
28297
- * The document's rendering, held for the one frame a view transition needs.
28298
- *
28299
- * The browser does not take the picture of the page being left when a
28300
- * transition is ASKED for — it takes it at the next frame, just before running
28301
- * the update callback. Preact renders sooner than that, in a microtask: so a
28302
- * change nobody asked for (a tab pressed, the back button) has already reached
28303
- * the DOM when the picture is taken, and the picture is of the page ARRIVING.
28304
- * Both sides of the animation then show it, and one watches a page slide onto
28305
- * itself.
28306
- *
28307
- * So what Preact has queued waits until the update callback, which is the
28308
- * moment the API is built around — the change belongs inside it. The whole
28309
- * document is held: it is about to be frozen under a picture anyway.
28310
- *
28311
- * ONE hold for the whole document, whoever animates. The hold is a wrapper
28312
- * around Preact's `options.debounceRendering`, and two of them installed
28313
- * independently restore each other in the wrong order when they let go — every
28314
- * render queued in between is then handed to a wrapper nobody will ever
28315
- * release. Everything that photographs a navigation (RouteTravel's box, a
28316
- * route transition) must therefore hold through this module, never through a
28317
- * wrapper of its own.
28318
- */
28319
-
28320
-
28321
- let renderingHold = null;
28322
- const holdRendering = () => {
28323
- if (renderingHold) {
28324
- return renderingHold.release;
28325
- }
28326
- const debounceRenderingBefore = options.debounceRendering;
28327
- const hold = {
28328
- render: null,
28329
- waiting: [],
28330
- release: () => {
28331
- // Only the hold that is still standing may be given back: a holder
28332
- // releasing after another has taken over must not let go of what it
28333
- // does not hold.
28334
- if (renderingHold !== hold) {
28335
- return;
28336
- }
28337
- renderingHold = null;
28338
- options.debounceRendering = debounceRenderingBefore;
28339
- const { render, waiting } = hold;
28340
- hold.render = null;
28341
- hold.waiting = [];
28342
- if (render) {
28343
- render();
28344
- }
28345
- for (const wait of waiting) {
28346
- wait();
28347
- }
28348
- },
28349
- };
28350
- renderingHold = hold;
28351
- options.debounceRendering = (render) => {
28352
- hold.render = render;
28353
- };
28354
- return hold.release;
28355
- };
28356
-
28357
- // Anything else that must not happen before the picture is taken, and the
28358
- // scroll is the other one: a page one arrives at starts at its top, and the
28359
- // document put back to its top while the page being left is still on screen is
28360
- // a page that has ALREADY jumped when the picture is taken. Worse, the browser
28361
- // paints what the new offset shows and nothing else, so the picture keeps only
28362
- // the band it had already painted — the page being left is then seen in
28363
- // fragments, whatever the movement does afterwards.
28364
- //
28365
- // Run at once when nobody is photographing anything, which is the common case
28366
- // and must stay free.
28367
- const whenRenderingResumes = (callback) => {
28368
- if (!renderingHold) {
28369
- callback();
28370
- return;
28371
- }
28372
- renderingHold.waiting.push(callback);
28373
- };
28374
-
28375
- // The hold a navigation takes on its way in — from before its first write,
28376
- // because by the time a route announces that it matches, Preact has already
28377
- // been told and the render is queued; a hold taken then is a hold taken too
28378
- // late. Kept here until whoever animates the change takes it over, or the
28379
- // navigation turns out to be one nobody animates.
28380
- let routingRenderingHold = null;
28381
- const holdRenderingForRouting = () => {
28382
- routingRenderingHold = holdRendering();
28383
- };
28384
- // Nobody had a picture to take: a page held for a change it does not animate
28385
- // is a page that stutters for nothing.
28386
- const releaseRoutingRenderingHold = () => {
28387
- const release = routingRenderingHold;
28388
- routingRenderingHold = null;
28389
- if (release) {
28390
- release();
28391
- }
28392
- };
28393
- // An animator takes the navigation's hold as its own — taking another would be
28394
- // taking a hold on a page that is holding still — or takes a fresh one when
28395
- // the change it animates is not a navigation.
28396
- const takeoverRoutingRenderingHold = () => {
28397
- const release = routingRenderingHold || holdRendering();
28398
- routingRenderingHold = null;
28399
- return release;
28400
- };
28401
-
28402
28628
  /**
28403
28629
  * Where a page was left, given back when one comes back to it.
28404
28630
  *
@@ -28599,6 +28825,11 @@ const restoreScrollPosition = (url) => {
28599
28825
  // So `from`, the url being left, tells an arrival from the same place said
28600
28826
  // differently, and only the first is moved.
28601
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
+ //
28602
28833
  // The document, because the document is the scrollport in the common case. An
28603
28834
  // app that scrolls an element of its own scrolls it itself.
28604
28835
  const startAtTop = (url, { from } = {}) => {
@@ -28608,14 +28839,10 @@ const startAtTop = (url, { from } = {}) => {
28608
28839
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
28609
28840
  };
28610
28841
  const isArrival = (url, { from }) => {
28611
- const urlObject = new URL(url, window.location.href);
28612
- // A fragment names where to land, and the browser is the one that finds it.
28613
- if (urlObject.hash) {
28614
- return false;
28615
- }
28616
28842
  if (
28617
28843
  from !== undefined &&
28618
- 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
28619
28846
  ) {
28620
28847
  return false;
28621
28848
  }
@@ -28692,29 +28919,6 @@ const forgetScrollerUnlessPageLeft = (name) => {
28692
28919
  }
28693
28920
  };
28694
28921
 
28695
- /**
28696
- * A navigation is ABOUT to be applied — said before its very first write.
28697
- *
28698
- * Everything else a router says arrives once the change is made: a route
28699
- * announces that it matches, an action that it is running. That is too late for
28700
- * anyone who needs the page as it stands BEFORE, and the browser's view
28701
- * transitions are exactly that kind of reader — the picture they keep of the
28702
- * page being left is taken at the next frame, and a render answering a signal
28703
- * written a moment ago is already in the DOM by then (see route_travel.jsx).
28704
- *
28705
- * So this is the one moment where nothing has moved yet. It is published
28706
- * synchronously, from the top of the navigation, and whoever listens runs
28707
- * before the URL, the visited set, or any route has changed.
28708
- *
28709
- * The other end is published too, and for the same kind of reader: whoever
28710
- * held something across the change and has nobody to hand it to gets a moment
28711
- * to let go of it that does not depend on guessing how long the change takes.
28712
- */
28713
-
28714
-
28715
- const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
28716
- const [publishAfterRouting, observeAfterRouting] = createPubSub();
28717
-
28718
28922
  const getHrefTargetInfo = (href) => {
28719
28923
  href = String(href);
28720
28924
 
@@ -29222,6 +29426,11 @@ const setupBrowserIntegrationViaHistory = ({
29222
29426
  });
29223
29427
 
29224
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
+ }
29225
29434
  // The entry a pending replace was for is no longer the current one, and
29226
29435
  // the History API has no way to write it (see replaceAddressWhenSettled).
29227
29436
  dropPendingReplace();
@@ -29297,9 +29506,14 @@ const setupBrowserIntegrationViaHistory = ({
29297
29506
  // one), so whoever awaits this reads a document url and state that
29298
29507
  // already say where it landed.
29299
29508
  const landedPromise = new Promise((resolve) => {
29300
- window.addEventListener("popstate", () => resolve(true), {
29301
- once: true,
29302
- });
29509
+ const onPopstate = () => {
29510
+ if (isTargetWriteInProgress()) {
29511
+ return;
29512
+ }
29513
+ window.removeEventListener("popstate", onPopstate);
29514
+ resolve(true);
29515
+ };
29516
+ window.addEventListener("popstate", onPopstate);
29303
29517
  });
29304
29518
  landOnPending = landOn || null;
29305
29519
  window.history.back();
@@ -34291,10 +34505,15 @@ const RouteTravel = ({
34291
34505
  // stopping. Which end decides how it is answered, and the two are not the
34292
34506
  // same amount of work.
34293
34507
  onEdge: ({
34294
- sign
34508
+ sign,
34509
+ thrown
34295
34510
  }) => {
34296
34511
  const travel = travelRef.current;
34297
- 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) {
34298
34517
  return false;
34299
34518
  }
34300
34519
  // Where the page in hand is coming from, said the way the gesture says
@@ -38327,22 +38546,28 @@ const armOutsidePressClose = (
38327
38546
  * frames, never a timeout: browsers hold timers back while a finger is down,
38328
38547
  * and a timeout started by the press fires at its release, on top of the click.
38329
38548
  *
38330
- * "while-opened" content is warmed by a press on the anchor, never by a hover
38331
- * or a focus. That mode promises content built fresh for the gesture that
38332
- * opens it, and mounted only while that gesture and its opening last: callers
38333
- * lean on it (several pickers sharing one set of content ids, because only one
38334
- * content exists at a time). A pointer crossing four anchors would build four
38335
- * contents; a press is the start of one opening. So the press builds it, and
38336
- * the press ending without an open throws it away — its click reaching the
38337
- * document with the popup still closed, no click coming after the release,
38338
- * the browser taking the gesture (`pointercancel`), or the next press
38339
- * starting, before any of its own handlers run: one press at a time holds a
38340
- * content. A keyboard opening
38341
- * builds at open time, as without warming. What this gives up: a press-warmed
38342
- * content is built before `onOpen` runs. The popups seeding their content from
38343
- * `onOpen` are opened ON something by a command, whose press is not on their
38344
- * anchor, so they are not warmed; one pressed on its own anchor that still
38345
- * seeds from `onOpen` reads the value from before it.
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.
38346
38571
  */
38347
38572
 
38348
38573
 
@@ -38476,21 +38701,21 @@ const usePopupContentMount = (
38476
38701
  anchorElement.removeEventListener("focusin", warm);
38477
38702
  };
38478
38703
  }, [contentMounted, anchor, mount]);
38479
- // Warm on the press, for "while-opened" (see the top comment). Listening for
38480
- // as long as the anchor is there, not only while the content is unmounted:
38481
- // the press that throws a warmed content away can be a new press on this
38482
- // same anchor, and it must warm again.
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.
38483
38708
  useEffect(() => {
38484
- if (!anchor || mount !== "while-opened") {
38709
+ if (mount !== "while-opened") {
38485
38710
  return undefined;
38486
38711
  }
38487
- const anchorElement = resolveAnchorElement(anchor);
38488
- if (!anchorElement) {
38712
+ const element = openController.getElement?.();
38713
+ if (!element) {
38489
38714
  return undefined;
38490
38715
  }
38491
38716
  let stopPressWarm = null;
38492
- const onPointerDown = (pointerdownEvent) => {
38493
- if (pointerdownEvent.button !== 0 || contentMountedRef.current) {
38717
+ const onOpeningPress = () => {
38718
+ if (contentMountedRef.current || openController.onOpen) {
38494
38719
  return;
38495
38720
  }
38496
38721
  const cancelBuild = requestFrameAfterNext(() => {
@@ -38508,12 +38733,12 @@ const usePopupContentMount = (
38508
38733
  stopWatching();
38509
38734
  };
38510
38735
  };
38511
- anchorElement.addEventListener("pointerdown", onPointerDown);
38736
+ element.addEventListener("navi_open_press", onOpeningPress);
38512
38737
  return () => {
38513
38738
  stopPressWarm?.();
38514
- anchorElement.removeEventListener("pointerdown", onPointerDown);
38739
+ element.removeEventListener("navi_open_press", onOpeningPress);
38515
38740
  };
38516
- }, [anchor, mount]);
38741
+ }, [mount]);
38517
38742
 
38518
38743
  return contentMounted;
38519
38744
  };
@@ -39641,6 +39866,9 @@ const useUIStateController = (
39641
39866
  // The suggestion this control started on — what tells a field showing
39642
39867
  // its default from one carrying an answer (see isUIStateHeld).
39643
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,
39644
39872
 
39645
39873
  facadeChild: null,
39646
39874
  // Set for the duration of one interaction by whatever wants the
@@ -39750,6 +39978,18 @@ const useUIStateController = (
39750
39978
  e.currentTarget || controller.ref.current,
39751
39979
  );
39752
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
+ }
39753
39993
  const stateIsTheSame = compareTwoJsValues(newUIState, currentUIState);
39754
39994
  if (stateIsTheSame) {
39755
39995
  if (controlType === "button" || controlType === "link") {
@@ -39915,7 +40155,12 @@ const useUIStateController = (
39915
40155
  }
39916
40156
  // initial_state_push is pure initialization (equivalent to defaultValue on the
39917
40157
  // child itself): skip uiAction entirely so no side effects fire on mount.
39918
- 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
+ ) {
39919
40164
  // Still fire uiAction so external listeners (e.g. signals) stay in
39920
40165
  // sync, but do NOT fire the command and do NOT notify the parent —
39921
40166
  // both would cause an infinite loop when a parent cascades state
@@ -39969,6 +40214,19 @@ const useUIStateController = (
39969
40214
  stateChanged: true,
39970
40215
  });
39971
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
+ }
39972
40230
  return true;
39973
40231
  }
39974
40232
  s.parentUIStateController?.onChildUIAction(controller, e, {
@@ -40076,6 +40334,41 @@ const useUIStateController = (
40076
40334
  // pick — the signal would hand it straight back on the next render,
40077
40335
  // and the refused value would win over the rollback.
40078
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
+ );
40079
40372
  },
40080
40373
  // Read by the callout manager when it has nowhere else to point.
40081
40374
  getCalloutAnchorElement: (event) =>
@@ -40089,7 +40382,12 @@ const useUIStateController = (
40089
40382
  if (controller.hasStateProp || controller.queuedActionAllowedEvent) {
40090
40383
  return;
40091
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();
40092
40389
  controller.state = controller.uiState;
40390
+ controller.followDefaultValue();
40093
40391
  },
40094
40392
  onActionEnd: (e) => {
40095
40393
  debugUIState(`"${controlType}" actionEnd called`);
@@ -40246,6 +40544,7 @@ const useUIStateController = (
40246
40544
  }
40247
40545
  }
40248
40546
  }
40547
+ controller.followDefaultValue();
40249
40548
  }
40250
40549
  return liveValues();
40251
40550
  },
@@ -40836,7 +41135,12 @@ const useUIGroupStateController = (
40836
41135
  // deferred alongside a mount sync stays a real change.
40837
41136
  const pendingChange = pendingChangeRef.current;
40838
41137
  pendingChangeRef.current = {
40839
- 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,
40840
41144
  notifyExternal:
40841
41145
  pendingChange?.notifyExternal === true ? true : notifyExternal,
40842
41146
  actingChild,
@@ -40844,6 +41148,13 @@ const useUIGroupStateController = (
40844
41148
  return;
40845
41149
  }
40846
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
+ }
40847
41158
  // A child mounting or unmounting is not somebody answering: while the
40848
41159
  // children of a group are still arriving, their aggregate is a partial
40849
41160
  // reading, and taking it for the truth is how the value the group was
@@ -41022,6 +41333,9 @@ const useUIGroupStateController = (
41022
41333
  name,
41023
41334
  value,
41024
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,
41025
41339
  hasValueProp,
41026
41340
  hasDefaultValueProp,
41027
41341
  props,
@@ -41305,6 +41619,7 @@ const useUIGroupStateController = (
41305
41619
  }
41306
41620
  }
41307
41621
  onChange(e, { notifyExternal: true });
41622
+ controller.followDefaultValue();
41308
41623
  },
41309
41624
  clearUIState: (e) => {
41310
41625
  const ev = new CustomEvent("propagate_down_clear_ui_state", {
@@ -41333,6 +41648,46 @@ const useUIGroupStateController = (
41333
41648
  c.acknowledgeUIState();
41334
41649
  }
41335
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
+ });
41336
41691
  },
41337
41692
  onActionEnd: (e) => {
41338
41693
  acknowledgeOwnAction(controller);
@@ -41455,6 +41810,7 @@ const useUIGroupStateController = (
41455
41810
  // former value back over it on the next child interaction.
41456
41811
  placeChildrenFrom(defaultValue);
41457
41812
  }
41813
+ controller.followDefaultValue();
41458
41814
 
41459
41815
  return liveValues();
41460
41816
  },
@@ -41681,7 +42037,11 @@ const useUIFacadeStateController = (props, realUIStateController) => {
41681
42037
  if (child !== firstChildControllerRef.current) {
41682
42038
  return;
41683
42039
  }
41684
- if (silent && uiStateHoldsNothing(child.uiState)) {
42040
+ if (
42041
+ silent &&
42042
+ !isOutsideValueFollow(e) &&
42043
+ uiStateHoldsNothing(child.uiState)
42044
+ ) {
41685
42045
  // A silent sync means the child's own structure changed (children
41686
42046
  // mounted/unmounted), not that the user acted. A child that ends up
41687
42047
  // with no value there is one that currently *cannot* express one —
@@ -41692,7 +42052,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
41692
42052
  // still nothing to adopt: the sync would only respell one nothing
41693
42053
  // as another (an array picker's [] becoming undefined, say) and
41694
42054
  // hand that to uiAction — an empty array picker opened its popup
41695
- // 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.
41696
42057
  return;
41697
42058
  }
41698
42059
  updatingRef.current = true;
@@ -41756,6 +42117,69 @@ const useUIFacadeStateController = (props, realUIStateController) => {
41756
42117
  return scope.controller;
41757
42118
  };
41758
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
+
41759
42183
  const describePicker = (props) =>
41760
42184
  `<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
41761
42185
 
@@ -41844,10 +42268,18 @@ const INTERNAL_EVENT_SET = new Set([
41844
42268
  // no action of the control's own — but what it holds really did move, so
41845
42269
  // uiAction, the bound signal and the parent notification below all happen.
41846
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",
41847
42276
  ]);
41848
42277
  const isInternalEvent = (e) => {
41849
42278
  return INTERNAL_EVENT_SET.has(e.type);
41850
42279
  };
42280
+ const isOutsideValueFollow = (e) => {
42281
+ return Boolean(findEvent(e, "outside_value_follow"));
42282
+ };
41851
42283
 
41852
42284
  /**
41853
42285
  * A value handed DOWN to a control by whoever owns it: a picker filling its
@@ -47381,7 +47813,11 @@ const usePreloadOpeningOnPress = (ref, command, prefetch = true) => {
47381
47813
  if (!element) {
47382
47814
  return undefined;
47383
47815
  }
47384
- const onPointerDown = () => {
47816
+ const onPointerDown = (pointerdownEvent) => {
47817
+ // A right click opens a menu, never the popup.
47818
+ if (pointerdownEvent.button !== 0) {
47819
+ return;
47820
+ }
47385
47821
  announceOpeningPress(element, command);
47386
47822
  };
47387
47823
  element.addEventListener("pointerdown", onPointerDown);
@@ -51480,8 +51916,14 @@ const Expandable = props => {
51480
51916
  // before the click ever fires — so what held the focus has to be remembered
51481
51917
  // at pointerdown time.
51482
51918
  const focusedAtPointerDownRef = useRef(null);
51483
- const onUIPointerDown = () => {
51919
+ const onUIPointerDown = pointerdownEvent => {
51484
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
+ }
51485
51927
  };
51486
51928
 
51487
51929
  // The content keeps its final size while the track animates (see the top
@@ -51779,17 +52221,18 @@ const Expandable = props => {
51779
52221
  if (clickEvent.defaultPrevented) {
51780
52222
  return;
51781
52223
  }
51782
- const {
51783
- target
51784
- } = clickEvent;
51785
- if (target.nodeType === 1) {
51786
- const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
51787
- if (interactiveElement && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement)) {
51788
- return;
51789
- }
52224
+ if (isAimedAtControlInsideUI(clickEvent.target)) {
52225
+ return;
51790
52226
  }
51791
52227
  toggle(clickEvent);
51792
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
+ };
51793
52236
 
51794
52237
  // Space/Enter on the UI part itself (role button) — a key pressed on a
51795
52238
  // control inside it belongs to that control.
@@ -60127,6 +60570,52 @@ const SlideContainer = ({
60127
60570
  drag.areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
60128
60571
  paintDrag();
60129
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
+ },
60130
60619
  onEnd: ({
60131
60620
  axis,
60132
60621
  sign,
@@ -64099,8 +64588,9 @@ const css$E = /* css */`
64099
64588
  * initial state: an uncontrolled field seeded from a `defaultValue` that
64100
64589
  * changed while the popup was closed. Intent on the anchor (pointer
64101
64590
  * entering it, focus landing in it) builds the content ahead of the click;
64102
- * under `"while-opened"`, only a press on it does, and a press that does not
64103
- * open the popup throws it away.
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.
64104
64594
  * @param {import("ignore:preact").ComponentChildren} props.children
64105
64595
  */
64106
64596
  const Dialog = props => {
@@ -65862,8 +66352,9 @@ const css$D = /* css */`
65862
66352
  * initial state: an uncontrolled field seeded from a `defaultValue` that
65863
66353
  * changed while the popup was closed. Intent on the anchor (pointer
65864
66354
  * entering it, focus landing in it) builds the content ahead of the click;
65865
- * under `"while-opened"`, only a press on it does, and a press that does not
65866
- * open the popup throws it away.
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.
65867
66358
  * @param {import("ignore:preact").ComponentChildren} props.children
65868
66359
  */
65869
66360
  const Popover = props => {
@@ -67536,7 +68027,10 @@ const PickerCustom = props => {
67536
68027
  // a defaultValue holds nothing, so closing on it untouched IS the answer
67537
68028
  // ("yes, 2h15") — the same rule Form applies to an untouched field (see
67538
68029
  // isUIStateHeld). Read at open, before anything inside can change it.
67539
- 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;
67540
68034
  debugPopup(openEvent, `picker opened, store value at open`, valueAtOpen, heldAtOpen ? `(held)` : `(a suggestion, not an answer yet)`);
67541
68035
  onOpen?.(openEvent);
67542
68036
  return {
@@ -67605,6 +68099,9 @@ const PickerCustom = props => {
67605
68099
  });
67606
68100
  chainEvent(rollbackEvent, closeEvent);
67607
68101
  inputEl.__uiStateController__.setUIState(valueAtOpen, rollbackEvent);
68102
+ if (statesAtOpen) {
68103
+ followOutsideSinceOpen(inputEl.__uiStateController__, statesAtOpen);
68104
+ }
67608
68105
  } else if (!heldAtOpen) {
67609
68106
  // Confirmed a suggestion: nothing changed, so nothing has told the
67610
68107
  // control's own bound signal / uiAction that this is now the
@@ -67696,6 +68193,11 @@ const PickerCustom = props => {
67696
68193
  "onnavi_request_open": e => {
67697
68194
  dispatchCustomEvent(popupRef.current, "navi_request_open", e.detail);
67698
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
+ },
67699
68201
  "onnavi_request_close": e => {
67700
68202
  const closing = dispatchCustomEvent(popupRef.current, "navi_request_close", e.detail);
67701
68203
  if (!closing) {
@@ -67890,6 +68392,25 @@ const PickerCustom = props => {
67890
68392
  // below), and the keyboard keeps its own ways in (the shortcuts above).
67891
68393
  const openOnList = Array.isArray(openOn) ? openOn : [openOn];
67892
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
+ }
67893
68414
  let interactions = props.interactions;
67894
68415
  if (!opensOnPress) {
67895
68416
  interactions = {