@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, 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 },
@@ -27538,112 +27870,6 @@ const dropGeneratedIdKeys = (state) => {
27538
27870
  return stateWithoutGeneratedIdKeys;
27539
27871
  };
27540
27872
 
27541
- /**
27542
- * The document's rendering, held for the one frame a view transition needs.
27543
- *
27544
- * The browser does not take the picture of the page being left when a
27545
- * transition is ASKED for — it takes it at the next frame, just before running
27546
- * the update callback. Preact renders sooner than that, in a microtask: so a
27547
- * change nobody asked for (a tab pressed, the back button) has already reached
27548
- * the DOM when the picture is taken, and the picture is of the page ARRIVING.
27549
- * Both sides of the animation then show it, and one watches a page slide onto
27550
- * itself.
27551
- *
27552
- * So what Preact has queued waits until the update callback, which is the
27553
- * moment the API is built around — the change belongs inside it. The whole
27554
- * document is held: it is about to be frozen under a picture anyway.
27555
- *
27556
- * ONE hold for the whole document, whoever animates. The hold is a wrapper
27557
- * around Preact's `options.debounceRendering`, and two of them installed
27558
- * independently restore each other in the wrong order when they let go — every
27559
- * render queued in between is then handed to a wrapper nobody will ever
27560
- * release. Everything that photographs a navigation (RouteTravel's box, a
27561
- * route transition) must therefore hold through this module, never through a
27562
- * wrapper of its own.
27563
- */
27564
-
27565
-
27566
- let renderingHold = null;
27567
- const holdRendering = () => {
27568
- if (renderingHold) {
27569
- return renderingHold.release;
27570
- }
27571
- const debounceRenderingBefore = options.debounceRendering;
27572
- const hold = {
27573
- render: null,
27574
- waiting: [],
27575
- release: () => {
27576
- // Only the hold that is still standing may be given back: a holder
27577
- // releasing after another has taken over must not let go of what it
27578
- // does not hold.
27579
- if (renderingHold !== hold) {
27580
- return;
27581
- }
27582
- renderingHold = null;
27583
- options.debounceRendering = debounceRenderingBefore;
27584
- const { render, waiting } = hold;
27585
- hold.render = null;
27586
- hold.waiting = [];
27587
- if (render) {
27588
- render();
27589
- }
27590
- for (const wait of waiting) {
27591
- wait();
27592
- }
27593
- },
27594
- };
27595
- renderingHold = hold;
27596
- options.debounceRendering = (render) => {
27597
- hold.render = render;
27598
- };
27599
- return hold.release;
27600
- };
27601
-
27602
- // Anything else that must not happen before the picture is taken, and the
27603
- // scroll is the other one: a page one arrives at starts at its top, and the
27604
- // document put back to its top while the page being left is still on screen is
27605
- // a page that has ALREADY jumped when the picture is taken. Worse, the browser
27606
- // paints what the new offset shows and nothing else, so the picture keeps only
27607
- // the band it had already painted — the page being left is then seen in
27608
- // fragments, whatever the movement does afterwards.
27609
- //
27610
- // Run at once when nobody is photographing anything, which is the common case
27611
- // and must stay free.
27612
- const whenRenderingResumes = (callback) => {
27613
- if (!renderingHold) {
27614
- callback();
27615
- return;
27616
- }
27617
- renderingHold.waiting.push(callback);
27618
- };
27619
-
27620
- // The hold a navigation takes on its way in — from before its first write,
27621
- // because by the time a route announces that it matches, Preact has already
27622
- // been told and the render is queued; a hold taken then is a hold taken too
27623
- // late. Kept here until whoever animates the change takes it over, or the
27624
- // navigation turns out to be one nobody animates.
27625
- let routingRenderingHold = null;
27626
- const holdRenderingForRouting = () => {
27627
- routingRenderingHold = holdRendering();
27628
- };
27629
- // Nobody had a picture to take: a page held for a change it does not animate
27630
- // is a page that stutters for nothing.
27631
- const releaseRoutingRenderingHold = () => {
27632
- const release = routingRenderingHold;
27633
- routingRenderingHold = null;
27634
- if (release) {
27635
- release();
27636
- }
27637
- };
27638
- // An animator takes the navigation's hold as its own — taking another would be
27639
- // taking a hold on a page that is holding still — or takes a fresh one when
27640
- // the change it animates is not a navigation.
27641
- const takeoverRoutingRenderingHold = () => {
27642
- const release = routingRenderingHold || holdRendering();
27643
- routingRenderingHold = null;
27644
- return release;
27645
- };
27646
-
27647
27873
  /**
27648
27874
  * Where a page was left, given back when one comes back to it.
27649
27875
  *
@@ -27844,6 +28070,11 @@ const restoreScrollPosition = (url) => {
27844
28070
  // So `from`, the url being left, tells an arrival from the same place said
27845
28071
  // differently, and only the first is moved.
27846
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
+ //
27847
28078
  // The document, because the document is the scrollport in the common case. An
27848
28079
  // app that scrolls an element of its own scrolls it itself.
27849
28080
  const startAtTop = (url, { from } = {}) => {
@@ -27853,14 +28084,10 @@ const startAtTop = (url, { from } = {}) => {
27853
28084
  window.scrollTo({ top: 0, left: 0, behavior: "instant" });
27854
28085
  };
27855
28086
  const isArrival = (url, { from }) => {
27856
- const urlObject = new URL(url, window.location.href);
27857
- // A fragment names where to land, and the browser is the one that finds it.
27858
- if (urlObject.hash) {
27859
- return false;
27860
- }
27861
28087
  if (
27862
28088
  from !== undefined &&
27863
- 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
27864
28091
  ) {
27865
28092
  return false;
27866
28093
  }
@@ -27937,29 +28164,6 @@ const forgetScrollerUnlessPageLeft = (name) => {
27937
28164
  }
27938
28165
  };
27939
28166
 
27940
- /**
27941
- * A navigation is ABOUT to be applied — said before its very first write.
27942
- *
27943
- * Everything else a router says arrives once the change is made: a route
27944
- * announces that it matches, an action that it is running. That is too late for
27945
- * anyone who needs the page as it stands BEFORE, and the browser's view
27946
- * transitions are exactly that kind of reader — the picture they keep of the
27947
- * page being left is taken at the next frame, and a render answering a signal
27948
- * written a moment ago is already in the DOM by then (see route_travel.jsx).
27949
- *
27950
- * So this is the one moment where nothing has moved yet. It is published
27951
- * synchronously, from the top of the navigation, and whoever listens runs
27952
- * before the URL, the visited set, or any route has changed.
27953
- *
27954
- * The other end is published too, and for the same kind of reader: whoever
27955
- * held something across the change and has nobody to hand it to gets a moment
27956
- * to let go of it that does not depend on guessing how long the change takes.
27957
- */
27958
-
27959
-
27960
- const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
27961
- const [publishAfterRouting, observeAfterRouting] = createPubSub();
27962
-
27963
28167
  const getHrefTargetInfo = (href) => {
27964
28168
  href = String(href);
27965
28169
 
@@ -28467,6 +28671,11 @@ const setupBrowserIntegrationViaHistory = ({
28467
28671
  });
28468
28672
 
28469
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
+ }
28470
28679
  // The entry a pending replace was for is no longer the current one, and
28471
28680
  // the History API has no way to write it (see replaceAddressWhenSettled).
28472
28681
  dropPendingReplace();
@@ -28542,9 +28751,14 @@ const setupBrowserIntegrationViaHistory = ({
28542
28751
  // one), so whoever awaits this reads a document url and state that
28543
28752
  // already say where it landed.
28544
28753
  const landedPromise = new Promise((resolve) => {
28545
- window.addEventListener("popstate", () => resolve(true), {
28546
- once: true,
28547
- });
28754
+ const onPopstate = () => {
28755
+ if (isTargetWriteInProgress()) {
28756
+ return;
28757
+ }
28758
+ window.removeEventListener("popstate", onPopstate);
28759
+ resolve(true);
28760
+ };
28761
+ window.addEventListener("popstate", onPopstate);
28548
28762
  });
28549
28763
  landOnPending = landOn || null;
28550
28764
  window.history.back();
@@ -33365,10 +33579,15 @@ const RouteTravel = ({
33365
33579
  // stopping. Which end decides how it is answered, and the two are not the
33366
33580
  // same amount of work.
33367
33581
  onEdge: ({
33368
- sign
33582
+ sign,
33583
+ thrown
33369
33584
  }) => {
33370
33585
  const travel = travelRef.current;
33371
- 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) {
33372
33591
  return false;
33373
33592
  }
33374
33593
  // Where the page in hand is coming from, said the way the gesture says
@@ -37329,22 +37548,28 @@ const armOutsidePressClose = (
37329
37548
  * frames, never a timeout: browsers hold timers back while a finger is down,
37330
37549
  * and a timeout started by the press fires at its release, on top of the click.
37331
37550
  *
37332
- * "while-opened" content is warmed by a press on the anchor, never by a hover
37333
- * or a focus. That mode promises content built fresh for the gesture that
37334
- * opens it, and mounted only while that gesture and its opening last: callers
37335
- * lean on it (several pickers sharing one set of content ids, because only one
37336
- * content exists at a time). A pointer crossing four anchors would build four
37337
- * contents; a press is the start of one opening. So the press builds it, and
37338
- * the press ending without an open throws it away — its click reaching the
37339
- * document with the popup still closed, no click coming after the release,
37340
- * the browser taking the gesture (`pointercancel`), or the next press
37341
- * starting, before any of its own handlers run: one press at a time holds a
37342
- * content. A keyboard opening
37343
- * builds at open time, as without warming. What this gives up: a press-warmed
37344
- * content is built before `onOpen` runs. The popups seeding their content from
37345
- * `onOpen` are opened ON something by a command, whose press is not on their
37346
- * anchor, so they are not warmed; one pressed on its own anchor that still
37347
- * seeds from `onOpen` reads the value from before it.
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.
37348
37573
  */
37349
37574
 
37350
37575
 
@@ -37478,21 +37703,21 @@ const usePopupContentMount = (
37478
37703
  anchorElement.removeEventListener("focusin", warm);
37479
37704
  };
37480
37705
  }, [contentMounted, anchor, mount]);
37481
- // Warm on the press, for "while-opened" (see the top comment). Listening for
37482
- // as long as the anchor is there, not only while the content is unmounted:
37483
- // the press that throws a warmed content away can be a new press on this
37484
- // same anchor, and it must warm again.
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.
37485
37710
  useEffect(() => {
37486
- if (!anchor || mount !== "while-opened") {
37711
+ if (mount !== "while-opened") {
37487
37712
  return undefined;
37488
37713
  }
37489
- const anchorElement = resolveAnchorElement(anchor);
37490
- if (!anchorElement) {
37714
+ const element = openController.getElement?.();
37715
+ if (!element) {
37491
37716
  return undefined;
37492
37717
  }
37493
37718
  let stopPressWarm = null;
37494
- const onPointerDown = (pointerdownEvent) => {
37495
- if (pointerdownEvent.button !== 0 || contentMountedRef.current) {
37719
+ const onOpeningPress = () => {
37720
+ if (contentMountedRef.current || openController.onOpen) {
37496
37721
  return;
37497
37722
  }
37498
37723
  const cancelBuild = requestFrameAfterNext(() => {
@@ -37510,12 +37735,12 @@ const usePopupContentMount = (
37510
37735
  stopWatching();
37511
37736
  };
37512
37737
  };
37513
- anchorElement.addEventListener("pointerdown", onPointerDown);
37738
+ element.addEventListener("navi_open_press", onOpeningPress);
37514
37739
  return () => {
37515
37740
  stopPressWarm?.();
37516
- anchorElement.removeEventListener("pointerdown", onPointerDown);
37741
+ element.removeEventListener("navi_open_press", onOpeningPress);
37517
37742
  };
37518
- }, [anchor, mount]);
37743
+ }, [mount]);
37519
37744
 
37520
37745
  return contentMounted;
37521
37746
  };
@@ -38641,6 +38866,9 @@ const useUIStateController = (
38641
38866
  // The suggestion this control started on — what tells a field showing
38642
38867
  // its default from one carrying an answer (see isUIStateHeld).
38643
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,
38644
38872
 
38645
38873
  facadeChild: null,
38646
38874
  // Set for the duration of one interaction by whatever wants the
@@ -38742,6 +38970,18 @@ const useUIStateController = (
38742
38970
  e.currentTarget || controller.ref.current,
38743
38971
  );
38744
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
+ }
38745
38985
  const stateIsTheSame = compareTwoJsValues(newUIState, currentUIState);
38746
38986
  if (stateIsTheSame) {
38747
38987
  if (controlType === "button" || controlType === "link") {
@@ -38907,7 +39147,12 @@ const useUIStateController = (
38907
39147
  }
38908
39148
  // initial_state_push is pure initialization (equivalent to defaultValue on the
38909
39149
  // child itself): skip uiAction entirely so no side effects fire on mount.
38910
- 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
+ ) {
38911
39156
  // Still fire uiAction so external listeners (e.g. signals) stay in
38912
39157
  // sync, but do NOT fire the command and do NOT notify the parent —
38913
39158
  // both would cause an infinite loop when a parent cascades state
@@ -38961,6 +39206,19 @@ const useUIStateController = (
38961
39206
  stateChanged: true,
38962
39207
  });
38963
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
+ }
38964
39222
  return true;
38965
39223
  }
38966
39224
  s.parentUIStateController?.onChildUIAction(controller, e, {
@@ -39056,6 +39314,41 @@ const useUIStateController = (
39056
39314
  // pick — the signal would hand it straight back on the next render,
39057
39315
  // and the refused value would win over the rollback.
39058
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
+ );
39059
39352
  },
39060
39353
  // Read by the callout manager when it has nowhere else to point.
39061
39354
  getCalloutAnchorElement: (event) =>
@@ -39069,7 +39362,12 @@ const useUIStateController = (
39069
39362
  if (controller.hasStateProp || controller.queuedActionAllowedEvent) {
39070
39363
  return;
39071
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();
39072
39369
  controller.state = controller.uiState;
39370
+ controller.followDefaultValue();
39073
39371
  },
39074
39372
  onActionEnd: (e) => {
39075
39373
  acknowledgeOwnAction(controller);
@@ -39224,6 +39522,7 @@ const useUIStateController = (
39224
39522
  }
39225
39523
  }
39226
39524
  }
39525
+ controller.followDefaultValue();
39227
39526
  }
39228
39527
  return liveValues();
39229
39528
  },
@@ -39804,7 +40103,12 @@ const useUIGroupStateController = (
39804
40103
  // deferred alongside a mount sync stays a real change.
39805
40104
  const pendingChange = pendingChangeRef.current;
39806
40105
  pendingChangeRef.current = {
39807
- 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,
39808
40112
  notifyExternal:
39809
40113
  pendingChange?.notifyExternal === true ? true : notifyExternal,
39810
40114
  actingChild,
@@ -39812,6 +40116,13 @@ const useUIGroupStateController = (
39812
40116
  return;
39813
40117
  }
39814
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
+ }
39815
40126
  // A child mounting or unmounting is not somebody answering: while the
39816
40127
  // children of a group are still arriving, their aggregate is a partial
39817
40128
  // reading, and taking it for the truth is how the value the group was
@@ -39990,6 +40301,9 @@ const useUIGroupStateController = (
39990
40301
  name,
39991
40302
  value,
39992
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,
39993
40307
  hasValueProp,
39994
40308
  hasDefaultValueProp,
39995
40309
  props,
@@ -40270,6 +40584,7 @@ const useUIGroupStateController = (
40270
40584
  }
40271
40585
  }
40272
40586
  onChange(e, { notifyExternal: true });
40587
+ controller.followDefaultValue();
40273
40588
  },
40274
40589
  clearUIState: (e) => {
40275
40590
  const ev = new CustomEvent("propagate_down_clear_ui_state", {
@@ -40298,6 +40613,46 @@ const useUIGroupStateController = (
40298
40613
  c.acknowledgeUIState();
40299
40614
  }
40300
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
+ });
40301
40656
  },
40302
40657
  onActionEnd: (e) => {
40303
40658
  acknowledgeOwnAction(controller);
@@ -40420,6 +40775,7 @@ const useUIGroupStateController = (
40420
40775
  // former value back over it on the next child interaction.
40421
40776
  placeChildrenFrom(defaultValue);
40422
40777
  }
40778
+ controller.followDefaultValue();
40423
40779
 
40424
40780
  return liveValues();
40425
40781
  },
@@ -40644,7 +41000,11 @@ const useUIFacadeStateController = (props, realUIStateController) => {
40644
41000
  if (child !== firstChildControllerRef.current) {
40645
41001
  return;
40646
41002
  }
40647
- if (silent && uiStateHoldsNothing(child.uiState)) {
41003
+ if (
41004
+ silent &&
41005
+ !isOutsideValueFollow(e) &&
41006
+ uiStateHoldsNothing(child.uiState)
41007
+ ) {
40648
41008
  // A silent sync means the child's own structure changed (children
40649
41009
  // mounted/unmounted), not that the user acted. A child that ends up
40650
41010
  // with no value there is one that currently *cannot* express one —
@@ -40655,7 +41015,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
40655
41015
  // still nothing to adopt: the sync would only respell one nothing
40656
41016
  // as another (an array picker's [] becoming undefined, say) and
40657
41017
  // hand that to uiAction — an empty array picker opened its popup
40658
- // 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.
40659
41020
  return;
40660
41021
  }
40661
41022
  updatingRef.current = true;
@@ -40719,6 +41080,69 @@ const useUIFacadeStateController = (props, realUIStateController) => {
40719
41080
  return scope.controller;
40720
41081
  };
40721
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
+
40722
41146
  const describePicker = (props) =>
40723
41147
  `<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
40724
41148
 
@@ -40783,10 +41207,18 @@ const INTERNAL_EVENT_SET = new Set([
40783
41207
  // no action of the control's own — but what it holds really did move, so
40784
41208
  // uiAction, the bound signal and the parent notification below all happen.
40785
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",
40786
41215
  ]);
40787
41216
  const isInternalEvent = (e) => {
40788
41217
  return INTERNAL_EVENT_SET.has(e.type);
40789
41218
  };
41219
+ const isOutsideValueFollow = (e) => {
41220
+ return Boolean(findEvent(e, "outside_value_follow"));
41221
+ };
40790
41222
 
40791
41223
  /**
40792
41224
  * A value handed DOWN to a control by whoever owns it: a picker filling its
@@ -46240,7 +46672,11 @@ const usePreloadOpeningOnPress = (ref, command, prefetch = true) => {
46240
46672
  if (!element) {
46241
46673
  return undefined;
46242
46674
  }
46243
- const onPointerDown = () => {
46675
+ const onPointerDown = (pointerdownEvent) => {
46676
+ // A right click opens a menu, never the popup.
46677
+ if (pointerdownEvent.button !== 0) {
46678
+ return;
46679
+ }
46244
46680
  announceOpeningPress(element, command);
46245
46681
  };
46246
46682
  element.addEventListener("pointerdown", onPointerDown);
@@ -50330,8 +50766,14 @@ const Expandable = props => {
50330
50766
  // before the click ever fires — so what held the focus has to be remembered
50331
50767
  // at pointerdown time.
50332
50768
  const focusedAtPointerDownRef = useRef(null);
50333
- const onUIPointerDown = () => {
50769
+ const onUIPointerDown = pointerdownEvent => {
50334
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
+ }
50335
50777
  };
50336
50778
 
50337
50779
  // The content keeps its final size while the track animates (see the top
@@ -50629,17 +51071,18 @@ const Expandable = props => {
50629
51071
  if (clickEvent.defaultPrevented) {
50630
51072
  return;
50631
51073
  }
50632
- const {
50633
- target
50634
- } = clickEvent;
50635
- if (target.nodeType === 1) {
50636
- const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
50637
- if (interactiveElement && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement)) {
50638
- return;
50639
- }
51074
+ if (isAimedAtControlInsideUI(clickEvent.target)) {
51075
+ return;
50640
51076
  }
50641
51077
  toggle(clickEvent);
50642
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
+ };
50643
51086
 
50644
51087
  // Space/Enter on the UI part itself (role button) — a key pressed on a
50645
51088
  // control inside it belongs to that control.
@@ -58941,6 +59384,52 @@ const SlideContainer = ({
58941
59384
  drag.areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
58942
59385
  paintDrag();
58943
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
+ },
58944
59433
  onEnd: ({
58945
59434
  axis,
58946
59435
  sign,
@@ -62831,8 +63320,9 @@ const css$E = /* css */`
62831
63320
  * initial state: an uncontrolled field seeded from a `defaultValue` that
62832
63321
  * changed while the popup was closed. Intent on the anchor (pointer
62833
63322
  * entering it, focus landing in it) builds the content ahead of the click;
62834
- * under `"while-opened"`, only a press on it does, and a press that does not
62835
- * open the popup throws it away.
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.
62836
63326
  * @param {import("ignore:preact").ComponentChildren} props.children
62837
63327
  */
62838
63328
  const Dialog = props => {
@@ -64512,8 +65002,9 @@ const css$D = /* css */`
64512
65002
  * initial state: an uncontrolled field seeded from a `defaultValue` that
64513
65003
  * changed while the popup was closed. Intent on the anchor (pointer
64514
65004
  * entering it, focus landing in it) builds the content ahead of the click;
64515
- * under `"while-opened"`, only a press on it does, and a press that does not
64516
- * open the popup throws it away.
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.
64517
65008
  * @param {import("ignore:preact").ComponentChildren} props.children
64518
65009
  */
64519
65010
  const Popover = props => {
@@ -66176,7 +66667,10 @@ const PickerCustom = props => {
66176
66667
  // a defaultValue holds nothing, so closing on it untouched IS the answer
66177
66668
  // ("yes, 2h15") — the same rule Form applies to an untouched field (see
66178
66669
  // isUIStateHeld). Read at open, before anything inside can change it.
66179
- 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;
66180
66674
  onOpen?.(openEvent);
66181
66675
  return {
66182
66676
  onRequestClose: requestCloseEvent => {
@@ -66244,6 +66738,9 @@ const PickerCustom = props => {
66244
66738
  });
66245
66739
  chainEvent(rollbackEvent, closeEvent);
66246
66740
  inputEl.__uiStateController__.setUIState(valueAtOpen, rollbackEvent);
66741
+ if (statesAtOpen) {
66742
+ followOutsideSinceOpen(inputEl.__uiStateController__, statesAtOpen);
66743
+ }
66247
66744
  } else if (!heldAtOpen) {
66248
66745
  // Confirmed a suggestion: nothing changed, so nothing has told the
66249
66746
  // control's own bound signal / uiAction that this is now the
@@ -66324,6 +66821,11 @@ const PickerCustom = props => {
66324
66821
  "onnavi_request_open": e => {
66325
66822
  dispatchCustomEvent(popupRef.current, "navi_request_open", e.detail);
66326
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
+ },
66327
66829
  "onnavi_request_close": e => {
66328
66830
  const closing = dispatchCustomEvent(popupRef.current, "navi_request_close", e.detail);
66329
66831
  if (!closing) {
@@ -66518,6 +67020,25 @@ const PickerCustom = props => {
66518
67020
  // below), and the keyboard keeps its own ways in (the shortcuts above).
66519
67021
  const openOnList = Array.isArray(openOn) ? openOn : [openOn];
66520
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
+ }
66521
67042
  let interactions = props.interactions;
66522
67043
  if (!opensOnPress) {
66523
67044
  interactions = {