@jsenv/navi 0.29.174 → 0.29.176

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,7 +4,7 @@
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, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, isTouchDrivenEvent, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
+ import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, isTouchDrivenEvent, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { chainEvent, clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
10
  import { isValidElement, h, Fragment, createContext, render, toChildArray, options, cloneElement } from "preact";
@@ -2905,6 +2905,15 @@ const compareTwoJsValues = (
2905
2905
  if (aKeys.length !== bKeys.length) {
2906
2906
  return false;
2907
2907
  }
2908
+ // The same number of keys is not the same keys, and only a's are walked
2909
+ // below. A key b does not have reads as undefined, so { include: undefined }
2910
+ // and { search: "a" } would compare "include" (undefined on both sides),
2911
+ // never look at "search", and pass for equal.
2912
+ for (const bKey of bKeys) {
2913
+ if (!Object.hasOwn(a, bKey)) {
2914
+ return false;
2915
+ }
2916
+ }
2908
2917
  if (aKeys.length === 0 && (!isPlainObject$1(a) || !isPlainObject$1(b))) {
2909
2918
  // A Set, a Map, an element, a URL: nothing enumerable to tell two of them
2910
2919
  // apart, so they are the same one or they are not — and they are not,
@@ -11529,6 +11538,17 @@ defineInteractionDetector({
11529
11538
  undo.push(() => {
11530
11539
  element.removeEventListener("dragstart", onDragStart);
11531
11540
  });
11541
+
11542
+ // A touch this element may take has to be refusable before the finger
11543
+ // lands, or the browser can cancel an established swipe mid-gesture by
11544
+ // scrolling the list around it along the axis touch-action leaves free —
11545
+ // same rule, same moment as the attributes above (see keepTouchRefusable).
11546
+ element.addEventListener("touchmove", keepTouchRefusable, {
11547
+ passive: false
11548
+ });
11549
+ undo.push(() => {
11550
+ element.removeEventListener("touchmove", keepTouchRefusable);
11551
+ });
11532
11552
  }
11533
11553
  if (hasLongPress) {
11534
11554
  mark(LONGPRESS_ATTRIBUTE, "");
@@ -31190,7 +31210,14 @@ const RouteTravel = ({
31190
31210
  ,
31191
31211
 
31192
31212
  "data-travel-by-drag": travelByDrag ? axis : undefined,
31193
- "data-travel-by-wheel": travelByDrag ? axis : undefined,
31213
+ "data-travel-by-wheel": travelByDrag ? axis : undefined
31214
+ // A touch this box may take has to be refusable before the finger lands,
31215
+ // or the browser can cancel an established travel mid-swipe by scrolling
31216
+ // the axis touch-action leaves it — see keepTouchRefusable for why a JSX
31217
+ // prop is enough (an element-level touchmove listener is non-passive).
31218
+ ,
31219
+
31220
+ onTouchMove: travelByDrag ? keepTouchRefusable : undefined,
31194
31221
  onPointerDown: onPointerDown,
31195
31222
  children: children
31196
31223
  });
@@ -34130,6 +34157,11 @@ const useUIStateController = (
34130
34157
 
34131
34158
  state: stateInitial,
34132
34159
  uiState: stateInitial,
34160
+ // What a bound signal last said — the change detector for writes coming
34161
+ // from the outside. Kept apart from `state` on purpose: `state` is the
34162
+ // last ACCEPTED value (the rollback target), and a signal echoing the
34163
+ // control's own unconfirmed write moves this field without moving it.
34164
+ stateFromSignalPrevious: stateInitial,
34133
34165
  uiStateSignal,
34134
34166
  // What this control holds by ITSELF — the same signal as above unless
34135
34167
  // it is a button inheriting from the control around it (see `inherit`).
@@ -34640,22 +34672,43 @@ const useUIStateController = (
34640
34672
  if (controller.hasStateProp) {
34641
34673
  controller.hasStateProp = false;
34642
34674
  controller.state = stateInitial;
34675
+ controller.stateFromSignalPrevious = stateInitial;
34643
34676
  }
34644
34677
  if (controlInfo.signal) {
34645
34678
  // The other half of a bound signal: the control follows it when
34646
- // something else writes it. Compared against `state` (what the signal
34647
- // last said), never against the ui state — otherwise typing into an
34679
+ // something else writes it. Compared against what the signal last
34680
+ // said, never against the ui state — otherwise typing into an
34648
34681
  // uncontrolled control would be undone on the next render. A write
34649
34682
  // coming from the control itself lands here with the value already in
34650
34683
  // place, and setUIState treats an unchanged state as a no-op.
34651
- const currentState = controller.state;
34652
- if (!compareTwoJsValues(stateFromSignal, currentState)) {
34653
- controller.state = stateFromSignal;
34654
- if (!optimisticWorkInFlight) {
34655
- controller.setUIState(
34656
- stateFromSignal,
34657
- new CustomEvent("state_prop_change"),
34658
- );
34684
+ if (
34685
+ !compareTwoJsValues(
34686
+ stateFromSignal,
34687
+ controller.stateFromSignalPrevious,
34688
+ )
34689
+ ) {
34690
+ controller.stateFromSignalPrevious = stateFromSignal;
34691
+ // On a control that runs an action, a signal matching the ui state
34692
+ // is the echo of the control's own write (setUIState wrote the
34693
+ // signal at the press — for a picker, at the pick, before the
34694
+ // close even runs the action). `state` is the rollback target —
34695
+ // the last value the outside accepted — and the echo must not
34696
+ // become it: resetOnError would then "restore" the very value the
34697
+ // server is about to refuse. The action settles `state` on its
34698
+ // own: success acknowledges (state = uiState), failure resets to
34699
+ // the state kept here. A control with no action has no such
34700
+ // settlement, so it keeps following the signal.
34701
+ const signalEchoesOwnWrite =
34702
+ Boolean(props.action) &&
34703
+ compareTwoJsValues(stateFromSignal, controller.uiState);
34704
+ if (!signalEchoesOwnWrite) {
34705
+ controller.state = stateFromSignal;
34706
+ if (!optimisticWorkInFlight) {
34707
+ controller.setUIState(
34708
+ stateFromSignal,
34709
+ new CustomEvent("state_prop_change"),
34710
+ );
34711
+ }
34659
34712
  }
34660
34713
  }
34661
34714
  }
@@ -37881,6 +37934,30 @@ const useInteractiveProps = (props, {
37881
37934
  resetOnError,
37882
37935
  optimistic
37883
37936
  } = props;
37937
+ // The DOM's word for the unconfirmed moment: an optimistic control paints
37938
+ // the value before the server accepts it, and stays interactive while the
37939
+ // run is out — aria-busy says "false" on purpose. This attribute is the
37940
+ // only trace of that moment, for an app that wants to dim or mark the
37941
+ // value until it is confirmed: `[data-optimistic]` on the control host and
37942
+ // its control root. Written at the run's boundaries rather than from
37943
+ // render: the bound action is a proxy following the UI state, so once the
37944
+ // state moves ahead of the running instance the proxy stops reporting the
37945
+ // run (same trap as the queue below).
37946
+ const syncOptimisticAttribute = on => {
37947
+ const el = ref.current;
37948
+ if (!el) {
37949
+ return;
37950
+ }
37951
+ const controlRoot = findControlRoot(el);
37952
+ const targets = controlRoot && controlRoot !== el ? [el, controlRoot] : [el];
37953
+ for (const target of targets) {
37954
+ if (on) {
37955
+ target.setAttribute("data-optimistic", "");
37956
+ } else {
37957
+ target.removeAttribute("data-optimistic");
37958
+ }
37959
+ }
37960
+ };
37884
37961
  Object.assign(controlHostProps, {
37885
37962
  onFocus: e => {
37886
37963
  // Transfer programmatic focus to the delegate target (navi-focus-delegate or navi-control-proxy-for)
@@ -37967,6 +38044,9 @@ const useInteractiveProps = (props, {
37967
38044
  uiStateController.pendingActionEvent = e.detail.event;
37968
38045
  uiStateController.actionInFlight = true;
37969
38046
  uiStateController.parallelGuard?.claim(uiStateController);
38047
+ if (optimistic) {
38048
+ syncOptimisticAttribute(true);
38049
+ }
37970
38050
  // The very instance this run uses, resolved now — while the UI state
37971
38051
  // still holds the value the run was made for. detail.action may be a
37972
38052
  // proxy following that state, and by the time anyone wants to abort
@@ -37984,6 +38064,7 @@ const useInteractiveProps = (props, {
37984
38064
  uiStateController.parallelGuard?.release(uiStateController);
37985
38065
  const queuedEvent = uiStateController.queuedActionAllowedEvent;
37986
38066
  if (!queuedEvent) {
38067
+ syncOptimisticAttribute(false);
37987
38068
  return;
37988
38069
  }
37989
38070
  if (outcome.error) {
@@ -37991,6 +38072,7 @@ const useInteractiveProps = (props, {
37991
38072
  // known state (resetOnError above), and what was queued was built
37992
38073
  // on top of the state that just failed.
37993
38074
  uiStateController.queuedActionAllowedEvent = null;
38075
+ syncOptimisticAttribute(false);
37994
38076
  return;
37995
38077
  }
37996
38078
  // A microtask later, not right here: this runs inside the batch()
@@ -45869,32 +45951,56 @@ const resolveAutoAnimationKind = (anchor, parsedPositionArea) => {
45869
45951
  * use_displayed_layout_effect.js).
45870
45952
  *
45871
45953
  * The `mount` prop moves that line. "closed" is two states, not one — never
45872
- * opened yet, and closed again after an opening — and the three values answer
45954
+ * opened yet, and closed again after an opening — and the four values answer
45873
45955
  * both at once:
45874
45956
  *
45875
- * | mount | before the first open | after a close |
45876
- * | ----------------- | --------------------- | ------------- |
45877
- * | "always" | mounted | mounted |
45878
- * | "from-first-open" | not mounted | mounted |
45879
- * | "while-opened" | not mounted | not mounted |
45957
+ * | mount | before the first open | after a close |
45958
+ * | ----------------- | --------------------------- | ------------- |
45959
+ * | "always" | mounted | mounted |
45960
+ * | "idle" | mounted once the page idles | mounted |
45961
+ * | "from-first-open" | not mounted | mounted |
45962
+ * | "while-opened" | not mounted | not mounted |
45880
45963
  *
45881
45964
  * "always" is for content something else depends on before any opening: a
45882
45965
  * value the popup's owner reads off its own children, fields a form around it
45883
45966
  * collects on submit, a size measured from outside.
45884
45967
  *
45968
+ * "idle" is "always" minus the cost on the critical render: the page appears
45969
+ * without the content, and the browser builds it in an idle moment after
45970
+ * load — so by the time anyone clicks, it is usually already there.
45971
+ *
45885
45972
  * "while-opened" is the opposite end: content that must be rebuilt from
45886
45973
  * scratch every time, because what it shows is read once at build time and can
45887
45974
  * change while the popup is closed — an uncontrolled field seeded from a
45888
45975
  * `defaultValue`, a form whose fresh state is its initial state.
45976
+ *
45977
+ * On top of whichever value is picked, intent on the anchor warms the content:
45978
+ * a pointer entering the popup's anchor, or focus landing in it, builds the
45979
+ * content ahead of the click that will open it. Deferring the build to the
45980
+ * opening puts its whole cost in the frame right after the click — the frame
45981
+ * where a delay is felt hardest — while the ~100-300ms between hovering a
45982
+ * trigger and pressing it are free. The warming render is asynchronous
45983
+ * (batched, not flushed): nothing here needs the content in the DOM before
45984
+ * the click, only before the open that follows it.
45889
45985
  */
45890
45986
 
45891
45987
 
45892
45988
  const MOUNT_DEFAULT = "from-first-open";
45893
45989
 
45990
+ // requestIdleCallback is missing from Safari; a timeout is close enough there.
45991
+ const requestIdle = (callback) =>
45992
+ typeof requestIdleCallback === "function"
45993
+ ? requestIdleCallback(callback)
45994
+ : setTimeout(callback, 300);
45995
+ const cancelIdle = (id) =>
45996
+ typeof cancelIdleCallback === "function"
45997
+ ? cancelIdleCallback(id)
45998
+ : clearTimeout(id);
45999
+
45894
46000
  const usePopupContentMount = (
45895
46001
  openController,
45896
46002
  ref,
45897
- { mount = MOUNT_DEFAULT },
46003
+ { mount = MOUNT_DEFAULT, anchor },
45898
46004
  ) => {
45899
46005
  const mountedAlways = mount === "always";
45900
46006
  const [contentMounted, setContentMounted] = useState(
@@ -45933,6 +46039,47 @@ const usePopupContentMount = (
45933
46039
  setContentMounted(true);
45934
46040
  }
45935
46041
  }, [mountedAlways]);
46042
+ useEffect(() => {
46043
+ if (mount !== "idle" || contentMounted) {
46044
+ return undefined;
46045
+ }
46046
+ const idleId = requestIdle(() => {
46047
+ setContentMounted(true);
46048
+ });
46049
+ return () => {
46050
+ cancelIdle(idleId);
46051
+ };
46052
+ }, [mount, contentMounted]);
46053
+ // Warm on intent (see the top comment). The anchor accepts the same shapes
46054
+ // Popover resolves at open time — a string id, a ref, an element — but is
46055
+ // resolved here at effect time: an id that matches nothing yet simply
46056
+ // doesn't warm, the open still mounts the content like it always does.
46057
+ useEffect(() => {
46058
+ if (contentMounted || !anchor) {
46059
+ return undefined;
46060
+ }
46061
+ const anchorElement =
46062
+ typeof anchor === "string"
46063
+ ? document.getElementById(anchor)
46064
+ : // A ref is unwrapped even when it holds nothing: an expandable with
46065
+ // no UI part hands an empty ref over, and the ref object itself is
46066
+ // truthy — it would reach addEventListener below and throw.
46067
+ "current" in anchor
46068
+ ? anchor.current
46069
+ : anchor;
46070
+ if (!anchorElement) {
46071
+ return undefined;
46072
+ }
46073
+ const warm = () => {
46074
+ setContentMounted(true);
46075
+ };
46076
+ anchorElement.addEventListener("pointerenter", warm);
46077
+ anchorElement.addEventListener("focusin", warm);
46078
+ return () => {
46079
+ anchorElement.removeEventListener("pointerenter", warm);
46080
+ anchorElement.removeEventListener("focusin", warm);
46081
+ };
46082
+ }, [contentMounted, anchor]);
45936
46083
 
45937
46084
  return contentMounted;
45938
46085
  };
@@ -46167,7 +46314,7 @@ const useExpandableContext = partName => {
46167
46314
  * openDirection?: "down" | "up" | "right" | "left",
46168
46315
  * autoFocus?: boolean,
46169
46316
  * maxContentHeight?: string | number,
46170
- * mount?: "always" | "from-first-open" | "while-opened",
46317
+ * mount?: "always" | "idle" | "from-first-open" | "while-opened",
46171
46318
  * arrowKeyShortcuts?: boolean,
46172
46319
  * openKeyShortcut?: string,
46173
46320
  * closeKeyShortcut?: string,
@@ -46222,14 +46369,17 @@ const useExpandableContext = partName => {
46222
46369
  * @param maxContentHeight - Caps the content height; taller content scrolls
46223
46370
  * inside the expandable instead of growing it.
46224
46371
  * @param mount - When the content is built and thrown away, a popup's own
46225
- * three values and a popup's own code (see popup_content_mount.js). `"from-first-open"` (the
46372
+ * values and a popup's own code (see popup_content_mount.js). `"from-first-open"` (the
46226
46373
  * default) builds it on the first expansion and keeps it afterwards.
46227
46374
  * `"always"` builds it right away; in layout="column" it also gives the
46228
46375
  * closed expandable its content's height (the content is kept laid out at
46229
46376
  * its open width), so opening only reveals the width instead of changing the
46230
- * height too. `"while-opened"` throws the content away once the collapse
46377
+ * height too. `"idle"` builds it in a browser idle moment after load.
46378
+ * `"while-opened"` throws the content away once the collapse
46231
46379
  * settles — after the closing animation, so it still plays on real
46232
- * content — and rebuilds it from scratch on every expansion.
46380
+ * content — and rebuilds it from scratch on every expansion. Whatever the
46381
+ * value, intent on the UI part (pointer entering it, focus landing in it)
46382
+ * builds the content ahead of the click.
46233
46383
  */
46234
46384
  const Expandable = props => {
46235
46385
  import.meta.css = [css$Q, "@jsenv/navi/src/control/expandable/expandable.jsx"];
@@ -46291,8 +46441,12 @@ const Expandable = props => {
46291
46441
  // Fully open (or fully closed) and no longer moving — what releases the
46292
46442
  // clipping, see the CSS.
46293
46443
  const [settled, setSettled] = useState(true);
46294
- const contentMounted = usePopupContentMount(openController, contentContainerRef, {
46295
- mount
46444
+ const contentMounted = usePopupContentMount(openController, contentContainerRef,
46445
+ // The UI part is what the user aims at to expand — the closest thing an
46446
+ // expandable has to a popup's anchor, warming the content on intent.
46447
+ {
46448
+ mount,
46449
+ anchor: uiRef
46296
46450
  });
46297
46451
 
46298
46452
  // The pointer press that is about to toggle can blur the focused field
@@ -55275,6 +55429,15 @@ const SlideContainer = ({
55275
55429
  ,
55276
55430
 
55277
55431
  "data-travel-by-drag": dragAxes ?? undefined
55432
+ // …and said as a listener too, from the same render: a touch this box may
55433
+ // take has to be refusable before the finger lands, and being registered
55434
+ // is the whole of it — see keepTouchRefusable for why a JSX prop is
55435
+ // enough here (an element-level touchmove listener is non-passive).
55436
+ // Without it, a swipe the box has taken can be cancelled mid-gesture by
55437
+ // the browser scrolling the page along the axis touch-action leaves it.
55438
+ ,
55439
+
55440
+ onTouchMove: dragAxes ? keepTouchRefusable : undefined
55278
55441
  // The same fact for a wheel, and only for that second reason: this box
55279
55442
  // takes the push, whatever the box around it also travels on.
55280
55443
  ,
@@ -57950,15 +58113,19 @@ const css$E = /* css */`
57950
58113
  * open controller (see `open_controller.js`) for a caller that wants to
57951
58114
  * drive open/close itself instead of `open`/`defaultOpen`/`onClose` (used
57952
58115
  * by `picker_custom.jsx`).
57953
- * @param {"always"|"from-first-open"|"while-opened"} [props.mount] - When
58116
+ * @param {"always"|"idle"|"from-first-open"|"while-opened"} [props.mount] - When
57954
58117
  * `children` are built and thrown away (see popup_content_mount.js).
57955
58118
  * `"from-first-open"` (the default) builds them on the first open and keeps
57956
58119
  * them afterwards. `"always"` builds them right away, for content something
57957
58120
  * depends on while the popup is still closed: a value read off it, fields a
57958
58121
  * surrounding form collects on submit, a size measured from outside.
57959
- * `"while-opened"` throws them away once the popup has finished closing, for
57960
- * content whose fresh state is its initial state: an uncontrolled field
57961
- * seeded from a `defaultValue` that changed while the popup was closed.
58122
+ * `"idle"` builds them in a browser idle moment after load — "always" minus
58123
+ * the cost on the critical render. `"while-opened"` throws them away once
58124
+ * the popup has finished closing, for content whose fresh state is its
58125
+ * initial state: an uncontrolled field seeded from a `defaultValue` that
58126
+ * changed while the popup was closed. Whatever the value, intent on the
58127
+ * anchor (pointer entering it, focus landing in it) builds the content
58128
+ * ahead of the click.
57962
58129
  * @param {import("ignore:preact").ComponentChildren} props.children
57963
58130
  */
57964
58131
  const Dialog = props => {
@@ -58203,7 +58370,8 @@ const useDialogProps = props => {
58203
58370
  // built: what this popup opens ON is known before anything reads it.
58204
58371
  openController.onOpen = onOpen || null;
58205
58372
  const contentMounted = usePopupContentMount(openController, props.ref, {
58206
- mount
58373
+ mount,
58374
+ anchor
58207
58375
  });
58208
58376
  const children = contentMounted ? childrenProp : null;
58209
58377
  const isModal = layer === "top";
@@ -58357,8 +58525,10 @@ const useDialogProps = props => {
58357
58525
  }
58358
58526
  } else if (anchor) {
58359
58527
  // anchor prop is a ref or a DOM element — always a real anchor,
58360
- // regardless of anchorCustomEventDetail.
58361
- anchorElement = anchor.current ?? anchor;
58528
+ // regardless of anchorCustomEventDetail. A ref is unwrapped even when it
58529
+ // holds nothing: falling back to the ref object itself would pass the
58530
+ // "is there an anchor?" test below with something that has no box.
58531
+ anchorElement = "current" in anchor ? anchor.current : anchor;
58362
58532
  } else if (anchorCustomEventDetail === "override") {
58363
58533
  // e.g. the button that triggered a --navi-toggle/--navi-open command,
58364
58534
  // already resolved from detail.anchor/detail.source by the caller
@@ -59417,15 +59587,19 @@ const css$D = /* css */`
59417
59587
  * open controller (see `open_controller.js`) for a caller that wants to
59418
59588
  * drive open/close itself instead of `open`/`defaultOpen`/`onClose` (used
59419
59589
  * by `picker_custom.jsx`/`side_panel.jsx`).
59420
- * @param {"always"|"from-first-open"|"while-opened"} [props.mount] - When
59590
+ * @param {"always"|"idle"|"from-first-open"|"while-opened"} [props.mount] - When
59421
59591
  * `children` are built and thrown away (see popup_content_mount.js).
59422
59592
  * `"from-first-open"` (the default) builds them on the first open and keeps
59423
59593
  * them afterwards. `"always"` builds them right away, for content something
59424
59594
  * depends on while the popup is still closed: a value read off it, fields a
59425
59595
  * surrounding form collects on submit, a size measured from outside.
59426
- * `"while-opened"` throws them away once the popup has finished closing, for
59427
- * content whose fresh state is its initial state: an uncontrolled field
59428
- * seeded from a `defaultValue` that changed while the popup was closed.
59596
+ * `"idle"` builds them in a browser idle moment after load — "always" minus
59597
+ * the cost on the critical render. `"while-opened"` throws them away once
59598
+ * the popup has finished closing, for content whose fresh state is its
59599
+ * initial state: an uncontrolled field seeded from a `defaultValue` that
59600
+ * changed while the popup was closed. Whatever the value, intent on the
59601
+ * anchor (pointer entering it, focus landing in it) builds the content
59602
+ * ahead of the click.
59429
59603
  * @param {import("ignore:preact").ComponentChildren} props.children
59430
59604
  */
59431
59605
  const Popover = props => {
@@ -59648,7 +59822,8 @@ const usePopoverProps = props => {
59648
59822
  // built: what this popup opens ON is known before anything reads it.
59649
59823
  openController.onOpen = onOpen || null;
59650
59824
  const contentMounted = usePopupContentMount(openController, props.ref, {
59651
- mount
59825
+ mount,
59826
+ anchor
59652
59827
  });
59653
59828
  const children = contentMounted ? childrenProp : null;
59654
59829
  const isTopLayer = layer === "top";
@@ -59763,8 +59938,10 @@ const usePopoverProps = props => {
59763
59938
  }
59764
59939
  } else if (anchor) {
59765
59940
  // anchor prop is a ref or a DOM element — always a real anchor,
59766
- // regardless of anchorCustomEventDetail.
59767
- anchorElement = anchor.current ?? anchor;
59941
+ // regardless of anchorCustomEventDetail. A ref is unwrapped even when it
59942
+ // holds nothing: falling back to the ref object itself would pass the
59943
+ // "is there an anchor?" test below with something that has no box.
59944
+ anchorElement = "current" in anchor ? anchor.current : anchor;
59768
59945
  } else if (anchorCustomEventDetail === "override") {
59769
59946
  anchorElement = e.detail.anchor;
59770
59947
  }
@@ -60588,7 +60765,7 @@ const css$C = /* css */`@layer navi {
60588
60765
  * @param {string} [props.className] - Merged with the shared
60589
60766
  * `"navi_popup"` class (see this file's own CSS) rather than replacing
60590
60767
  * it.
60591
- * @param {"always"|"from-first-open"|"while-opened"} [props.mount] - When
60768
+ * @param {"always"|"idle"|"from-first-open"|"while-opened"} [props.mount] - When
60592
60769
  * `children` are built and thrown away (see popup_content_mount.js).
60593
60770
  * `"from-first-open"` (the default) builds them on the first open and keeps
60594
60771
  * them afterwards. `"always"` builds them right away, for content something
@@ -60885,7 +61062,13 @@ const PickerCustom = props => {
60885
61062
  // What a `--navi-confirm` said inside the popup means, once the popup has
60886
61063
  // closed on it. A confirm picker is the one saying something (see
60887
61064
  // picker_confirm.jsx): its press, deferred until the question is answered.
60888
- onConfirm
61065
+ onConfirm,
61066
+ // The popup's lifecycle, the same pair Dialog and Popover take. Taken on
61067
+ // the picker and chained into its own openController below: the picker
61068
+ // hands the popup that controller, and a controlled Dialog/Popover reads
61069
+ // no onOpen/onClose of its own.
61070
+ onOpen,
61071
+ onClose
60889
61072
  } = props;
60890
61073
  // Resolve the id the same way useControlProps does (own id > Field's id > generated id)
60891
61074
  // before computing popupId below, so two Pickers without an explicit id never collide.
@@ -60919,6 +61102,8 @@ const PickerCustom = props => {
60919
61102
  delete pickerProps.defaultOpen;
60920
61103
  delete pickerProps.escapeEffect;
60921
61104
  delete pickerProps.onConfirm;
61105
+ delete pickerProps.onOpen;
61106
+ delete pickerProps.onClose;
60922
61107
  // Read below for the popup alone; on the trigger it would land on the DOM as
60923
61108
  // an unknown attribute holding a ref object.
60924
61109
  delete pickerProps.anchor;
@@ -60927,6 +61112,13 @@ const PickerCustom = props => {
60927
61112
  popupProps,
60928
61113
  actionEvent: "custom"
60929
61114
  });
61115
+ if (pickerProps.resetOnError === undefined) {
61116
+ // The picker's action fails after its popup closed on the value: nobody is
61117
+ // left mid-edit, so the value the server refused rolls back to the last
61118
+ // accepted one and the error callout says why — the same default
61119
+ // PickerNative takes.
61120
+ pickerProps.resetOnError = true;
61121
+ }
60930
61122
  // ref
60931
61123
  const popupRef = useRef(null);
60932
61124
  popupProps.ref = popupRef;
@@ -60983,6 +61175,7 @@ const PickerCustom = props => {
60983
61175
  // isUIStateHeld). Read at open, before anything inside can change it.
60984
61176
  const heldAtOpen = isUIStateHeld(getPickerInput(ref.current)?.__uiStateController__);
60985
61177
  debugPopup(openEvent, `picker opened, store value at open`, valueAtOpen, heldAtOpen ? `(held)` : `(a suggestion, not an answer yet)`);
61178
+ onOpen?.(openEvent);
60986
61179
  return {
60987
61180
  onRequestClose: requestCloseEvent => {
60988
61181
  if (requestCloseEvent.detail.isCancel) {
@@ -61065,6 +61258,9 @@ const PickerCustom = props => {
61065
61258
  if (confirmEvent && !closeEvent.detail.isCancel) {
61066
61259
  onConfirm?.(confirmEvent);
61067
61260
  }
61261
+ // Last, after the value bookkeeping above: whoever listens reads the
61262
+ // picker's value as it ends up — committed, or restored on a cancel.
61263
+ onClose?.(closeEvent);
61068
61264
  }
61069
61265
  };
61070
61266
  });
@@ -82127,7 +82323,14 @@ const SidePanel = ({
82127
82323
  ,
82128
82324
 
82129
82325
  "data-drag-travel": swipeToClose ? SWIPE_AXIS_BY_SIDE[side] : undefined,
82130
- "data-travel-by-drag": swipeToClose ? SWIPE_AXIS_BY_SIDE[side] : undefined,
82326
+ "data-travel-by-drag": swipeToClose ? SWIPE_AXIS_BY_SIDE[side] : undefined
82327
+ // A touch this panel may take has to be refusable before the finger
82328
+ // lands, or the browser can cancel the close gesture mid-swipe by
82329
+ // scrolling the panel's content — see keepTouchRefusable for why a JSX
82330
+ // prop is enough (an element-level touchmove listener is non-passive).
82331
+ ,
82332
+
82333
+ onTouchMove: swipeToClose ? keepTouchRefusable : undefined,
82131
82334
  ...rest,
82132
82335
  onPointerDown: pointerDownEvent => {
82133
82336
  rest.onPointerDown?.(pointerDownEvent);