@jsenv/navi 0.29.73 → 0.29.75

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, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { coarsePointerSignal, disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, clickIsSuppressed, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
7
+ import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, clickIsSuppressed, isTouchDrivenEvent, scrollIntoViewScoped, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
8
8
  export { clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, batch, untracked, useSignal } from "@preact/signals";
10
10
  import { createContext, isValidElement, h, Fragment, render, toChildArray, options, cloneElement } from "preact";
@@ -19345,10 +19345,48 @@ import.meta.css = [/* css */`
19345
19345
  outline-offset: calc(-1 * var(--navi-focus-outline-width));
19346
19346
  overflow: auto;
19347
19347
 
19348
+ /* The same reading as the header's corners above, on all four: a body
19349
+ follows the corners of the box it is drawn in — which is also what
19350
+ it clips its content to, the overflow just above. */
19351
+ border-top-left-radius: inherit;
19352
+ border-top-right-radius: inherit;
19353
+ border-bottom-right-radius: inherit;
19354
+ border-bottom-left-radius: inherit;
19355
+
19348
19356
  &:focus-visible {
19349
19357
  outline-style: solid;
19350
19358
  }
19351
19359
  }
19360
+
19361
+ /* A corner a header or a footer covers is not the body's to follow:
19362
+ what the body meets there is their flat separator line, not a curve,
19363
+ and a radius against it shows the box through the gap it opens. */
19364
+ > [data-header] ~ [data-body] {
19365
+ border-top-left-radius: 0;
19366
+ border-top-right-radius: 0;
19367
+ }
19368
+ > [data-body]:has(~ [data-footer]) {
19369
+ border-bottom-right-radius: 0;
19370
+ border-bottom-left-radius: 0;
19371
+ }
19372
+
19373
+ /* A body with no padding of its own holds content running edge to edge:
19374
+ whatever sits at one of its ends is drawn ON the corner the body just
19375
+ resolved, so a radius of its own there carves a notch out of it. The
19376
+ body already clips to that corner, which makes "none" the right radius
19377
+ for what lands on it — square, and the body draws the curve. The ask
19378
+ travels down as a corner claim (see group.jsx) so a navi control
19379
+ answers it wherever it sits inside. */
19380
+ > [data-body][data-body-flush] {
19381
+ > :first-child {
19382
+ --x-corner-top-left-radius: 0;
19383
+ --x-corner-top-right-radius: 0;
19384
+ }
19385
+ > :last-child {
19386
+ --x-corner-bottom-right-radius: 0;
19387
+ --x-corner-bottom-left-radius: 0;
19388
+ }
19389
+ }
19352
19390
  }
19353
19391
  }
19354
19392
 
@@ -19551,6 +19589,11 @@ const Box = props => {
19551
19589
  }
19552
19590
  if (body) {
19553
19591
  rest["data-body"] = "";
19592
+ // Padding is what decides whether the content reaches the body's own
19593
+ // corners — see the corner claims in this file's CSS.
19594
+ if (!PADDING_PROP_NAMES.some(name => isNonZeroSpacing(rest[name]))) {
19595
+ rest["data-body-flush"] = "";
19596
+ }
19554
19597
  }
19555
19598
  const defaultDisplay = getDefaultDisplay(TagName);
19556
19599
  // Read the parent flow early so we can use it when display="inherit" is requested.
@@ -20015,6 +20058,16 @@ const shouldInjectSeparatorBetween = (left, right) => {
20015
20058
  }
20016
20059
  return true;
20017
20060
  };
20061
+ const PADDING_PROP_NAMES = ["padding", "paddingX", "paddingY", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"];
20062
+ const isNonZeroSpacing = value => {
20063
+ if (value === undefined || value === null || value === false) {
20064
+ return false;
20065
+ }
20066
+ if (value === 0 || value === "0" || value === "none") {
20067
+ return false;
20068
+ }
20069
+ return true;
20070
+ };
20018
20071
 
20019
20072
  const useDebounceTrue = (value, delay = 300) => {
20020
20073
  const [debouncedTrue, setDebouncedTrue] = useState(false);
@@ -28546,7 +28599,21 @@ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
28546
28599
  focusedElement,
28547
28600
  focusVisible,
28548
28601
 
28549
- transferFocus: (transferEvent, containerEl) => {
28602
+ /**
28603
+ * Moves the focus into `containerEl`, on the element the ladder above
28604
+ * picks.
28605
+ *
28606
+ * `getDelay(target)` — asked once the target is known, answers how many
28607
+ * milliseconds to wait before actually focusing it. The ladder is what
28608
+ * decides WHO gets the focus and it may only run once (it consumes the
28609
+ * autofocus-restore mark), so a caller with a policy about WHEN cannot
28610
+ * resolve the target itself to make up its mind: it is handed the answer
28611
+ * instead. Returns a cancel function when it did delay, so a container
28612
+ * closing before the delay is up takes back a focus it never gave;
28613
+ * undefined when it focused straight away and there is nothing to take
28614
+ * back.
28615
+ */
28616
+ transferFocus: (transferEvent, containerEl, { getDelay } = {}) => {
28550
28617
  let target;
28551
28618
  let reason;
28552
28619
  const lastFocused = clearAutofocusRestore(containerEl);
@@ -28574,24 +28641,39 @@ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
28574
28641
  }
28575
28642
  }
28576
28643
  if (!target) {
28577
- return;
28644
+ return undefined;
28578
28645
  }
28579
28646
  // The modality speaks for the transfer, but an editable target outranks
28580
28647
  // it: it draws its ring on any focus (see isMatchingFocusVisible), so
28581
28648
  // the native :focus-visible is told the same.
28582
28649
  const targetFocusVisible = focusVisible || isEditableTarget(target);
28650
+ const giveFocus = () => {
28651
+ debugFocus(
28652
+ transferEvent,
28653
+ `Moving focus to ${getElementSignature(target)}.focus({ preventScroll: true, focusVisible: ${targetFocusVisible} }) (reason: ${reason})`,
28654
+ );
28655
+ target.focus({
28656
+ preventScroll: true,
28657
+ focusVisible: targetFocusVisible,
28658
+ });
28659
+ if (target.hasAttribute("navi-autofocus-select")) {
28660
+ target.select();
28661
+ target.scrollLeft = 0;
28662
+ }
28663
+ };
28664
+ const delay = getDelay?.(target) || 0;
28665
+ if (!delay) {
28666
+ giveFocus();
28667
+ return undefined;
28668
+ }
28583
28669
  debugFocus(
28584
28670
  transferEvent,
28585
- `Moving focus to ${getElementSignature(target)}.focus({ preventScroll: true, focusVisible: ${targetFocusVisible} }) (reason: ${reason})`,
28671
+ `Delaying focus to ${getElementSignature(target)} by ${delay}ms`,
28586
28672
  );
28587
- target.focus({
28588
- preventScroll: true,
28589
- focusVisible: targetFocusVisible,
28590
- });
28591
- if (target.hasAttribute("navi-autofocus-select")) {
28592
- target.select();
28593
- target.scrollLeft = 0;
28594
- }
28673
+ const timeout = setTimeout(giveFocus, delay);
28674
+ return () => {
28675
+ clearTimeout(timeout);
28676
+ };
28595
28677
  },
28596
28678
 
28597
28679
  restoreFocus: (restoreEvent) => {
@@ -28633,6 +28715,21 @@ const getFocusedBeforeTransfer = (e) => {
28633
28715
  return document.activeElement;
28634
28716
  };
28635
28717
 
28718
+ // How long a popup waits before handing the focus to a field, when giving it
28719
+ // is what raises the on-screen keyboard.
28720
+ //
28721
+ // The focus is normally given as early as possible. But a popup places itself
28722
+ // against the viewport, and on a phone the keyboard takes a third of that
28723
+ // viewport away the moment a field receives focus — so the two landing in the
28724
+ // same tick means the popup is still arriving when the room under it changes,
28725
+ // and it re-places itself mid-entrance. Waiting lets it settle first, and the
28726
+ // keyboard then shrinks a box that has stopped moving.
28727
+ //
28728
+ // Long enough to outlast an entrance transition rather than merely reaching
28729
+ // the next frame: what has to be over is the popup MOVING, not one paint of
28730
+ // it.
28731
+ const FOCUS_DELAY_ON_KEYBOARD_MS = 250;
28732
+
28636
28733
  /**
28637
28734
  * Owns open/close decision-making for a popup (Dialog or Popover): guards
28638
28735
  * against duplicate requests and notifies the popup owner's own reactions.
@@ -28871,8 +28968,32 @@ const createOpenController = (
28871
28968
  // once mousedown.preventDefault() has kept focus from landing
28872
28969
  // anywhere yet.
28873
28970
 
28874
- focusTransfer.transferFocus(e, el);
28971
+ // Two conditions, and both are about THIS opening rather than about
28972
+ // the device:
28973
+ // - the interaction: only a finger raises a virtual keyboard, and a
28974
+ // hybrid tablet answers "coarse" to every device-level signal
28975
+ // whichever of its two inputs was just used — the open event still
28976
+ // remembers which one it was. An opening with no pointer in it at
28977
+ // all (a keyboard shortcut, defaultOpen, an app calling open()) is
28978
+ // not one either.
28979
+ // - the target: focusing a button raises nothing, so there is nothing
28980
+ // to wait for and the focus stays immediate. Only a field the
28981
+ // keyboard comes up for is worth delaying — which is why the
28982
+ // decision is taken on the resolved target, inside transferFocus.
28983
+ const openedByTouch = Boolean(
28984
+ findEvent(requestOpenEvent, isTouchDrivenEvent),
28985
+ );
28986
+ const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
28987
+ getDelay: (target) =>
28988
+ openedByTouch && isEditableTarget(target)
28989
+ ? FOCUS_DELAY_ON_KEYBOARD_MS
28990
+ : 0,
28991
+ });
28875
28992
  return (closeEvent) => {
28993
+ // Closed before the delay was up: the focus was never given, so it
28994
+ // must not be given now — to a field inside a popup on its way out,
28995
+ // raising the keyboard as it goes.
28996
+ cancelPendingFocus?.();
28876
28997
  markAutofocusRestoreOnClose(el, closeEvent, focusedAtClose);
28877
28998
  const focusoutEvent = findEvent(closeEvent, "focusout");
28878
28999
  if (focusoutEvent) {
@@ -29141,6 +29262,60 @@ const flushSyncRendering = (fn) => {
29141
29262
  */
29142
29263
 
29143
29264
 
29265
+ /**
29266
+ * Whether a visibleRectEffect delivery is one that can have taken height away
29267
+ * from a popup, and so pushed whatever holds focus out of sight:
29268
+ * - "resize": the window/visual viewport settled — which is also how the
29269
+ * on-screen keyboard arrives, overlay or not (window_size.js);
29270
+ * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
29271
+ * - "focusin": the focus-settled re-measure, for the room that changes with
29272
+ * nothing announcing it (subscribeFocusSettled in window_size.js).
29273
+ *
29274
+ * Everything else is a scroll of one kind or another, where nothing got
29275
+ * smaller and scrolling the focused element back would fight the very gesture
29276
+ * that fired it.
29277
+ */
29278
+ const mayHaveHiddenFocus = (event) => {
29279
+ const type = event?.type;
29280
+ return (
29281
+ type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
29282
+ );
29283
+ };
29284
+
29285
+ /**
29286
+ * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
29287
+ * getting shorter has pushed it out.
29288
+ *
29289
+ * The case this exists for: a field low in the scrolling body of a popup that
29290
+ * also has a footer (box.jsx — with a body, the body is the only thing that
29291
+ * scrolls and the footer is a sibling sitting right under it). Focusing the
29292
+ * field makes the browser scroll it into view, which it does against the
29293
+ * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
29294
+ * that height away. The body shrinks, its scrollTop does not move, so the
29295
+ * content slides down relative to the shorter scrollport and the field ends up
29296
+ * past its bottom edge — visually, swallowed by the footer. The browser does
29297
+ * not redo a scroll-into-view it already answered, so this does.
29298
+ *
29299
+ * Scoped to the field's own scroll container (never the page): a popup traps
29300
+ * scrolling precisely so the document underneath cannot move, and a plain
29301
+ * scrollIntoView walks past a container whose scrollbar isn't visible — see
29302
+ * scrollIntoViewScoped's own doc.
29303
+ *
29304
+ * "nearest": the smallest scroll that makes it visible, and none at all when
29305
+ * it already is — so this is free to call on every resize, and never fights
29306
+ * where the user had scrolled to.
29307
+ */
29308
+ const keepFocusedElementVisible = (popupEl) => {
29309
+ const { activeElement } = document;
29310
+ if (!activeElement || activeElement === popupEl) {
29311
+ return;
29312
+ }
29313
+ if (!popupEl.contains(activeElement)) {
29314
+ return;
29315
+ }
29316
+ scrollIntoViewScoped(activeElement, { block: "nearest" });
29317
+ };
29318
+
29144
29319
  /**
29145
29320
  * Calls `onSettled` once `el`'s current CSS transition is over — via
29146
29321
  * `transitionend`, with a safety `setTimeout` fallback matching the longest
@@ -29852,10 +30027,13 @@ installImportMetaCssBuild(import.meta);/**
29852
30027
  * `positionArea` accepts the same grammar Popover does (see
29853
30028
  * popup_shared.js), even though several combinations land identically here
29854
30029
  * since Dialog is never really anchored — kept distinct anyway because
29855
- * `positionArea` still picks which animation direction plays. `anchor` only
29856
- * ever affects the `--anchor-width`/`--anchor-height` CSS vars (sizing the
29857
- * dialog relative to whatever opened it) Dialog's own positioning is never
29858
- * relative to it, unlike Popover.
30030
+ * `positionArea` still picks which animation direction plays. `anchor` is
30031
+ * inert here unless `sizeFromAnchor` asks for it: a dialog is a surface of
30032
+ * its own, sized by its content, not a panel grown out of the control that
30033
+ * opened it that is Popover's job. With `sizeFromAnchor`, the anchor's box
30034
+ * reaches the `--anchor-width`/`--anchor-height` CSS vars and becomes a
30035
+ * min-width/min-height floor. Either way Dialog's own positioning is never
30036
+ * relative to the anchor, unlike Popover.
29859
30037
  *
29860
30038
  * Two rendering strategies, picked via `layer`: `DialogAsModal` (a real
29861
30039
  * `<dialog>`, `showModal()`, top layer — native focus trap,
@@ -30340,17 +30518,24 @@ const css$X = /* css */`
30340
30518
  * value is used as-is.
30341
30519
  * @param {string} [props.animationDuration] - Maps to
30342
30520
  * `--popup-animation-duration`.
30343
- * @param {Element|{current: Element}|string} [props.anchor] - Only ever sizes
30344
- * the dialog via the `--anchor-width`/`--anchor-height` CSS vars never
30345
- * used for positioning (see this file's top comment). Defaults to whatever
30521
+ * @param {Element|{current: Element}|string} [props.anchor] - Never used for
30522
+ * positioning (see this file's top comment), and ignored entirely unless
30523
+ * `sizeFromAnchor` is set then it sizes the dialog via the
30524
+ * `--anchor-width`/`--anchor-height` CSS vars. Defaults to whatever
30346
30525
  * triggered the open (`e.detail.anchor`), if any. A string is resolved via
30347
30526
  * `document.getElementById` when the dialog opens — see popover.jsx's own
30348
30527
  * `anchor` doc for why (mainly `defaultOpen`).
30528
+ * @param {boolean} [props.sizeFromAnchor=false] - Whether the dialog takes the
30529
+ * anchor's width/height as a min-width/min-height floor
30530
+ * (`--anchor-width`/`--anchor-height`). Off by default: unlike a popover,
30531
+ * a dialog is not attached to what opened it, so following that element's
30532
+ * box is a deliberate choice (a picker-style surface meant to read as the
30533
+ * trigger's own continuation), not the norm.
30349
30534
  * @param {"override"|"ignore"} [props.anchorCustomEventDetail="override"] -
30350
30535
  * Whether an explicit `anchor` prop takes precedence over (`"override"`,
30351
30536
  * default) or is ignored in favor of (`"ignore"`) whatever anchor the
30352
30537
  * triggering event carried. Same prop as Popover's, applied to the only
30353
- * thing an anchor does here: sizing (`--anchor-width`/`--anchor-height`).
30538
+ * thing an anchor can do here: sizing, and only under `sizeFromAnchor`.
30354
30539
  * @param {string} [props.minWidth] - Maps to `--dialog-min-width`; clamped
30355
30540
  * so it can never push the dialog past `--dialog-maxmax-width` (the
30356
30541
  * viewport/container-spacing ceiling) regardless of how large a value is
@@ -30609,12 +30794,15 @@ const useDialogProps = props => {
30609
30794
  // once, held at that size while open. See this prop's own JSDoc above.
30610
30795
  sizing = "auto",
30611
30796
  animation,
30612
- // Only ever affects --anchor-width/--anchor-height (see this file's top
30613
- // comment) — Dialog's own positioning is never relative to it.
30797
+ // Inert unless sizeFromAnchor below (see this file's top comment) —
30798
+ // Dialog's own positioning is never relative to it.
30614
30799
  anchor,
30800
+ // Opt-in: --anchor-width/--anchor-height are only set when this is true.
30801
+ // See this prop's own JSDoc above for why a dialog does not follow its
30802
+ // trigger's box by default.
30803
+ sizeFromAnchor = false,
30615
30804
  // Same meaning as Popover's own prop, applied to the only thing an anchor
30616
- // does here: sizing. "ignore" is how a dialog that must not inherit its
30617
- // trigger's width says so (SidePanel does exactly that).
30805
+ // can do here: sizing under sizeFromAnchor.
30618
30806
  anchorCustomEventDetail = "override",
30619
30807
  // Makes the dialog itself a valid focus target so
30620
30808
  // autoFocus="last-resort" below has somewhere to land when it contains
@@ -30809,7 +30997,7 @@ const useDialogProps = props => {
30809
30997
  // see openLocalDialogCount's own comment
30810
30998
  dialogEl.style.setProperty("--dialog-stack-order", openLocalDialogCount++);
30811
30999
  }
30812
- if (anchorElement) {
31000
+ if (sizeFromAnchor && anchorElement) {
30813
31001
  const {
30814
31002
  width,
30815
31003
  height
@@ -30977,6 +31165,11 @@ const useDialogProps = props => {
30977
31165
  // handled generically by applyNewPosition itself (dispatches
30978
31166
  // navi_position_change on every call) — nothing to do here.
30979
31167
  };
31168
+ // Cleared here rather than on close, where the box is deliberately left
31169
+ // frozen at the size it was closing at (see the closing function's own
31170
+ // comment): this opening has its own content to be measured against, and
31171
+ // measuring it inside last time's box would answer with last time's size.
31172
+ unfreezeSize(dialogEl);
30980
31173
  positionDialog();
30981
31174
  if (sizing === "frozen") {
30982
31175
  // After positionDialog: the caps it writes
@@ -30999,6 +31192,12 @@ const useDialogProps = props => {
30999
31192
  event
31000
31193
  }) => {
31001
31194
  positionDialog(event);
31195
+ // Only for what can have taken height away from the dialog — a
31196
+ // scroll never does, and re-scrolling on one would fight the finger
31197
+ // that caused it. See keepFocusedElementVisible's own doc.
31198
+ if (mayHaveHiddenFocus(event)) {
31199
+ keepFocusedElementVisible(dialogEl);
31200
+ }
31002
31201
  }, {
31003
31202
  event: e,
31004
31203
  skipElementResize: true
@@ -31051,6 +31250,12 @@ const useDialogProps = props => {
31051
31250
  }
31052
31251
  const hasCssTransitionAnimation = Boolean(resolvedAnimation);
31053
31252
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(dialogEl) : null;
31253
+ // Handing the focus to a field is what raises the on-screen keyboard, and
31254
+ // the keyboard takes away the very room this dialog was just placed
31255
+ // against — so on a touch-driven opening the transfer waits for the
31256
+ // entrance to be over. Decided by transferFocusOnOpen, the only place that
31257
+ // knows WHICH element is about to be focused (open_controller.js and its
31258
+ // FOCUS_DELAY_ON_KEYBOARD_MS).
31054
31259
  const restoreFocus = openController.transferFocusOnOpen(dialogEl);
31055
31260
 
31056
31261
  // isModal outside-click detection (see this file's top comment for why
@@ -31116,9 +31321,16 @@ const useDialogProps = props => {
31116
31321
  // property is actually present — harmless the rest of the time.
31117
31322
  dialogEl.setAttribute("navi-hidden", "");
31118
31323
  dialogEl.close();
31119
- // The freeze only ever holds for one opening: the next one has its own
31120
- // content to be measured against.
31121
- unfreezeSize(dialogEl);
31324
+ // Held at the size it has right now, for the whole way out. cleanup()
31325
+ // below already stops the JS repositioning, but the size is CSS-driven
31326
+ // (--x-dialog-max-height, and `height` outright under expandY) and
31327
+ // keeps following the visual viewport on its own — so a dialog closed
31328
+ // while the keyboard is up grows back to fill the room the keyboard is
31329
+ // giving back, WHILE fading out. Coherent, and still wrong to watch: a
31330
+ // box being dismissed has nothing left to adapt to, and the growth
31331
+ // reads as something happening at the exact moment nothing should. The
31332
+ // next opening clears it (see openEffect's own unfreezeSize).
31333
+ freezeSize(dialogEl);
31122
31334
  cancelOpenInteractionSuppression?.();
31123
31335
  if (hasCssTransitionAnimation) {
31124
31336
  suppressPointerEventsDuringTransition(dialogEl);
@@ -32430,6 +32642,12 @@ const usePopoverProps = props => {
32430
32642
  }
32431
32643
  popoverEl.removeAttribute("data-anchor-out-of-view");
32432
32644
  positionPopover(event);
32645
+ // Same as Dialog's own — a popup with a scrolling body and a footer
32646
+ // swallows the field it just got shorter around. See
32647
+ // keepFocusedElementVisible's own doc in popup_shared.js.
32648
+ if (mayHaveHiddenFocus(event)) {
32649
+ keepFocusedElementVisible(popoverEl);
32650
+ }
32433
32651
  }, {
32434
32652
  event: e,
32435
32653
  // it's ok for the popover to become unsync with the anchor size
@@ -38817,24 +39035,34 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
38817
39035
  // included).
38818
39036
 
38819
39037
  const css$T = /* css */`
38820
- /* The marked region is a picture of its own during every view transition of
38821
- the documentwhich is what keeps it out of the root snapshot, where its
38822
- place would otherwise be blank. */
38823
- [data-navi-route-transition-area] {
39038
+ /* The marked region is a picture of its own for the length of a transition of
39039
+ OURS, and only then the name is what makes the pages a picture the
39040
+ movement below can carry.
39041
+
39042
+ Named outside that, it would be a picture during every view transition the
39043
+ APPLICATION starts — two rows swapping, a list changing — and a page is
39044
+ several screens tall: its picture is the whole element, drawn in the top
39045
+ layer from wherever the element starts, so it paints over the fixed bars
39046
+ and past the bottom of the screen for the length of a movement that has
39047
+ nothing to do with the pages. Unnamed, it stays part of the document's own
39048
+ picture, where the browser cuts it at the viewport like everything else. */
39049
+ :root[data-navi-route-transition] [data-navi-route-transition-area] {
38824
39050
  view-transition-name: navi-route-transition;
38825
39051
  }
38826
39052
 
38827
- /* A named descendant — a row named for a reorder gesture, a thumbnail named
38828
- for a morph — is a hole in the area's picture and a group of its own at the
38829
- top of the tree: it stands still and cross-fades on its own clock while the
38830
- pages move. Nested groups put it back INSIDE the area's picture, so it
38831
- travels with the pages and is cut at their edge. Said here rather than
38832
- erasing the name: a name inside the area is legitimate, and "contain" says
38833
- "these move with the page" where "none" would say "these do not exist". A
38834
- browser without nested groups is warned instead (see
39053
+ /* A named descendant — a thumbnail named for a morph, a row named for a
39054
+ reorder gesture — is a hole in the area's picture and a group of its own.
39055
+ Nested groups keep that group inside the area's, which is what cuts it at
39056
+ the pages' edge instead of letting it paint across the screen. What it does
39057
+ NOT do is make it travel: the movement is carried by the area's two
39058
+ pictures, and a group is not one of them, so a named descendant stands
39059
+ where it was captured while the pages slide under it. A morph wants exactly
39060
+ that; a component that names its parts for changes of its own does not, and
39061
+ drops its names for the length of the movement (see list.jsx). A browser
39062
+ without nested groups is warned instead (see
38835
39063
  warnAboutNamesEscapingArea). */
38836
39064
  @supports (view-transition-group: contain) {
38837
- [data-navi-route-transition-area] {
39065
+ :root[data-navi-route-transition] [data-navi-route-transition-area] {
38838
39066
  view-transition-group: contain;
38839
39067
  }
38840
39068
  }
@@ -45723,9 +45951,12 @@ installImportMetaCssBuild(import.meta);/**
45723
45951
  * along the bar it adds to the padding asked for. Note that every
45724
45952
  * `env(safe-area-inset-*)` is 0 unless the page asks for it:
45725
45953
  * `<meta name="viewport" content="…, viewport-fit=cover">`.
45726
- * 4. **Its hairline is a box-shadow, not a border.** A real border would eat
45727
- * into the size; a box-shadow draws the identical line and stays out of
45728
- * layout.
45954
+ * 4. **Its hairline is part of its box.** The line covers the content just as
45955
+ * the bar does, so the room given back has to include it a line drawn
45956
+ * outside the box (a box-shadow, an outline) is a line the content scrolls
45957
+ * under, and a line a page transition paints over. It is a real border,
45958
+ * added to the size asked for exactly like the notch inset is, so the
45959
+ * content still gets the size the prop names.
45729
45960
  */
45730
45961
  const css$M = /* css */`
45731
45962
  @layer navi {
@@ -45785,39 +46016,50 @@ const css$M = /* css */`
45785
46016
  }
45786
46017
 
45787
46018
  /* Across the bar, the inset of the edge it is pinned to is padding AND is
45788
- added to the size: the background then runs under the notch while the
45789
- content keeps the whole width/height asked for. */
46019
+ added to the size, and the hairline on the content side is added the
46020
+ same way: the background then runs under the notch, the line stands
46021
+ clear of the content, and the content keeps the whole width/height asked
46022
+ for. */
45790
46023
  &[data-area="top"] {
45791
46024
  top: var(--navi-app-inset-top);
45792
- height: calc(var(--navi-fixed-bar-height) + env(safe-area-inset-top));
46025
+ height: calc(
46026
+ var(--navi-fixed-bar-height) + env(safe-area-inset-top) +
46027
+ var(--navi-fixed-bar-border-width)
46028
+ );
45793
46029
  padding-top: env(safe-area-inset-top);
45794
- box-shadow: 0 var(--navi-fixed-bar-border-width) 0
46030
+ border-bottom: var(--navi-fixed-bar-border-width) solid
45795
46031
  var(--navi-fixed-bar-border-color);
45796
46032
  }
45797
46033
  &[data-area="bottom"] {
45798
46034
  bottom: var(--navi-app-inset-bottom);
45799
- height: calc(var(--navi-fixed-bar-height) + env(safe-area-inset-bottom));
46035
+ height: calc(
46036
+ var(--navi-fixed-bar-height) + env(safe-area-inset-bottom) +
46037
+ var(--navi-fixed-bar-border-width)
46038
+ );
45800
46039
  padding-bottom: env(safe-area-inset-bottom);
45801
- box-shadow: 0 calc(-1 * var(--navi-fixed-bar-border-width)) 0
46040
+ border-top: var(--navi-fixed-bar-border-width) solid
45802
46041
  var(--navi-fixed-bar-border-color);
45803
46042
  }
45804
46043
  &[data-area="left"] {
45805
46044
  left: var(--navi-app-inset-left);
45806
- width: calc(var(--navi-fixed-bar-width) + env(safe-area-inset-left));
46045
+ width: calc(
46046
+ var(--navi-fixed-bar-width) + env(safe-area-inset-left) +
46047
+ var(--navi-fixed-bar-border-width)
46048
+ );
45807
46049
  padding-left: env(safe-area-inset-left);
45808
- box-shadow: var(--navi-fixed-bar-border-width) 0 0
46050
+ border-right: var(--navi-fixed-bar-border-width) solid
45809
46051
  var(--navi-fixed-bar-border-color);
45810
46052
  }
45811
46053
  &[data-area="right"] {
45812
46054
  right: var(--navi-app-inset-right);
45813
- width: calc(var(--navi-fixed-bar-width) + env(safe-area-inset-right));
46055
+ width: calc(
46056
+ var(--navi-fixed-bar-width) + env(safe-area-inset-right) +
46057
+ var(--navi-fixed-bar-border-width)
46058
+ );
45814
46059
  padding-right: env(safe-area-inset-right);
45815
- box-shadow: calc(-1 * var(--navi-fixed-bar-border-width)) 0 0
46060
+ border-left: var(--navi-fixed-bar-border-width) solid
45816
46061
  var(--navi-fixed-bar-border-color);
45817
46062
  }
45818
- &[data-border="none"] {
45819
- box-shadow: none;
45820
- }
45821
46063
  }
45822
46064
  `;
45823
46065
  const FixedBarStyleCSSVars = {
@@ -45851,9 +46093,10 @@ const FixedBarStyleCSSVars = {
45851
46093
  * @param {string|number} [props.width] - …and for one on a side. The safe-area
45852
46094
  * inset is NOT part of it: it is added on top, so the content keeps the size
45853
46095
  * asked for.
45854
- * @param {boolean} [props.border=true] - The hairline on the content side.
45855
- * Drawn with a box-shadow so it never eats into the size; give it a
45856
- * `borderWidth`/`borderColor`, or `border={false}` for none.
46096
+ * @param {boolean} [props.border=true] - The hairline on the content side. It
46097
+ * is added to the size rather than taken out of it, and counts in the room
46098
+ * the bar gives back; give it a `borderWidth`/`borderColor`, or
46099
+ * `border={false}` for none.
45857
46100
  * @param {string|number} [props.maxWidth] - Keeps the bar lined up with a
45858
46101
  * content column narrower than the window (it stays centered).
45859
46102
  */
@@ -45866,9 +46109,16 @@ const FixedBar = ({
45866
46109
  import.meta.css = [css$M, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
45867
46110
  const defaultRef = useRef();
45868
46111
  props.ref = props.ref || defaultRef;
46112
+ // Said with the width the border rule reads rather than with an attribute of
46113
+ // its own: the width is what the size calc adds, so a line asked away here
46114
+ // is a line that takes no room either.
46115
+ if (!border) {
46116
+ props.borderWidth = "0px";
46117
+ }
45869
46118
  // Whichever of width/height crosses the edge the bar sits on is what the
45870
46119
  // content has to be given back — and the bar's border box already IS that:
45871
- // the size it was given plus the inset of that edge. Measured rather than
46120
+ // the size it was given, the inset of that edge, and the hairline standing
46121
+ // between it and the content. Measured rather than
45872
46122
  // rebuilt as a calc() expression, so a size coming from anywhere — a prop, a
45873
46123
  // theme variable, the content itself — is reserved just the same, and each
45874
46124
  // `env()` inset stays the browser's business alone.
@@ -45915,7 +46165,6 @@ const FixedBar = ({
45915
46165
  return jsx(Box, {
45916
46166
  baseClassName: "navi_fixed_bar",
45917
46167
  "data-area": area,
45918
- "data-border": border ? undefined : "none",
45919
46168
  ...props,
45920
46169
  styleCSSVars: FixedBarStyleCSSVars,
45921
46170
  children: children
@@ -54442,9 +54691,9 @@ installImportMetaCssBuild(import.meta);/**
54442
54691
  *
54443
54692
  * `layer` (shared by both — picks the top-layer vs. local-container rendering
54444
54693
  * strategy either way) and `anchorCustomEventDetail` (shared too: Popover
54445
- * resolves an anchor to position against, Dialog to size itself from) pass
54446
- * through untouched via `...rest` to whichever of Popover/Dialog actually
54447
- * renders.
54694
+ * resolves an anchor to position against, Dialog only to size itself from,
54695
+ * and only under its own `sizeFromAnchor`) pass through untouched via
54696
+ * `...rest` to whichever of Popover/Dialog actually renders.
54448
54697
  */
54449
54698
  const css$A = /* css */`
54450
54699
  @layer navi {
@@ -54482,13 +54731,12 @@ const css$A = /* css */`
54482
54731
  * @param {"top"|"local"} [props.layer] - Forwarded as-is to whichever of
54483
54732
  * `Dialog`/`Popover` renders — see either component's own doc.
54484
54733
  * @param {Element|{current: Element}} [props.anchor] - Forwarded as-is —
54485
- * sizing-only for `Dialog`, positioning for `Popover` (see each
54486
- * component's own doc for what it actually does there).
54734
+ * positioning for `Popover`, and for `Dialog` sizing only, and only when
54735
+ * `sizeFromAnchor` is also passed (see each component's own doc).
54487
54736
  * @param {"override"|"ignore"} [props.anchorCustomEventDetail] - Forwarded
54488
54737
  * as-is to both — what it governs differs (positioning for `Popover`,
54489
- * sizing for `Dialog`), but "ignore whatever anchor the triggering event
54490
- * carried" has to mean the same thing in either mode, or the same
54491
- * `<Popup>` usage silently picks up its trigger's width on small screens.
54738
+ * `sizeFromAnchor` sizing for `Dialog`), but "ignore whatever anchor the
54739
+ * triggering event carried" has to mean the same thing in either mode.
54492
54740
  * @param {string} [props.marginWithAnchor] - **Popover-only**, destructured
54493
54741
  * out so it can't leak onto the real `<dialog>` element as a stray DOM
54494
54742
  * attribute when `mode="dialog"` is picked.
@@ -54691,12 +54939,17 @@ installImportMetaCssBuild(import.meta);const css$z = /* css */`
54691
54939
 
54692
54940
  /* No fallback on purpose (same as --popover-max-height above): unset
54693
54941
  picker props leave these declarations invalid at computed-value
54694
- time, so the dialog keeps its own ceilings. */
54942
+ time, so the dialog keeps its own floors/ceilings. */
54943
+ --dialog-min-width: var(--picker-dialog-min-width);
54944
+ --dialog-min-height: var(--picker-dialog-min-height);
54695
54945
  --dialog-max-width: var(--picker-dialog-max-width);
54696
54946
  --dialog-max-height: var(--picker-dialog-max-height);
54697
54947
 
54698
- /* Dialog itself already sizes min-width off --anchor-width only
54699
- the cursor reset below is picker-specific here. */
54948
+ /* Nothing bridges the trigger's width in here: a dialog does not
54949
+ follow its anchor's box (dialog.jsx, sizeFromAnchor) it is not
54950
+ visually attached to the trigger, so it is sized by its content,
54951
+ and dialogMinWidth/dialogMinHeight are how a caller says otherwise.
54952
+ Only the cursor reset below is picker-specific here. */
54700
54953
  cursor: default; /* Reset pointer cursor within the select */
54701
54954
 
54702
54955
  /* Dialog already applies display: flex to [open] itself, but
@@ -54718,24 +54971,10 @@ installImportMetaCssBuild(import.meta);const css$z = /* css */`
54718
54971
  }
54719
54972
 
54720
54973
  /* popupWidthFitContent (picker.jsx): drop the trigger-width floor so the
54721
- popup shrinks to its content. Inherits down to the popover. */
54974
+ popup shrinks to its content. Popover-only the dialog has no such
54975
+ floor to drop (see the dialog block above). */
54722
54976
  &[data-popup-width-fit-content] {
54723
54977
  --picker-popover-min-width: 0px;
54724
-
54725
- /* The popover var above only reaches the popover — the dialog reads
54726
- --anchor-width directly for its own min-width floor (dialog.jsx). A
54727
- modal dialog isn't visually attached to the trigger, so with
54728
- fit-content we drop that floor here too, letting the content size the
54729
- dialog like the popover. (More specific than dialog.jsx's own
54730
- .navi_dialog rule; both are unlayered, so this wins.) */
54731
- &[aria-haspopup="dialog"] {
54732
- .navi_dialog {
54733
- min-width: min(
54734
- var(--dialog-min-width, 0px),
54735
- var(--x-dialog-max-width)
54736
- );
54737
- }
54738
- }
54739
54978
  }
54740
54979
  }
54741
54980
  `;
@@ -57048,7 +57287,26 @@ const css$w = /* css */`
57048
57287
  flex-direction: column;
57049
57288
  background-color: var(--x-list-background-color);
57050
57289
  border: var(--x-list-border-width) solid var(--x-list-border-color);
57051
- border-radius: var(--x-list-border-radius);
57290
+ /* Squared from the outside, corner by corner: whoever draws the surface
57291
+ the list is laid on says which corners are the list's to draw (a popup's
57292
+ body does, see box.jsx), and each corner falls back to the list's own
57293
+ radius when nothing asks for anything. */
57294
+ border-top-left-radius: var(
57295
+ --x-corner-top-left-radius,
57296
+ var(--x-list-border-radius)
57297
+ );
57298
+ border-top-right-radius: var(
57299
+ --x-corner-top-right-radius,
57300
+ var(--x-list-border-radius)
57301
+ );
57302
+ border-bottom-right-radius: var(
57303
+ --x-corner-bottom-right-radius,
57304
+ var(--x-list-border-radius)
57305
+ );
57306
+ border-bottom-left-radius: var(
57307
+ --x-corner-bottom-left-radius,
57308
+ var(--x-list-border-radius)
57309
+ );
57052
57310
 
57053
57311
  transition: opacity 0.2s ease;
57054
57312
  /* overflow:hidden is required on the container (not the inner scroll element)
@@ -57057,6 +57315,13 @@ const css$w = /* css */`
57057
57315
  overflow: hidden;
57058
57316
 
57059
57317
  .navi_list_scroll_container {
57318
+ /* The ask stops here: this element is inside the list's frame, so a row
57319
+ or a control it holds is not at the surface's corner. */
57320
+ --x-corner-top-left-radius: initial;
57321
+ --x-corner-top-right-radius: initial;
57322
+ --x-corner-bottom-right-radius: initial;
57323
+ --x-corner-bottom-left-radius: initial;
57324
+
57060
57325
  width: inherit;
57061
57326
  min-width: inherit;
57062
57327
  max-width: var(--list-max-width, inherit);
@@ -57530,9 +57795,19 @@ const css$w = /* css */`
57530
57795
  without being contained animate across the page (the pictures live in the
57531
57796
  top layer, where no overflow of the document reaches them), which is worse
57532
57797
  than not animating at all. So a browser with no nested groups gets no name
57533
- either, and the change simply happens. */
57798
+ either, and the change simply happens.
57799
+
57800
+ Named for a change of the list's own, and for that alone: while the PAGES
57801
+ are the ones moving — a route transition, a route travel — the list is part
57802
+ of what travels, and a picture of its own is precisely what does not
57803
+ travel. A page is carried by its own picture; anything named inside it is
57804
+ lifted out of that picture into a group of its own, which stays where it
57805
+ was captured while the page slides away under it. So the names are dropped
57806
+ for the length of such a movement and the list crosses the screen with the
57807
+ page, as a block. */
57534
57808
  @supports (view-transition-group: contain) {
57535
- .navi_list_container[data-item-transition] {
57809
+ :root:not([data-navi-route-transition], [data-navi-route-travel])
57810
+ .navi_list_container[data-item-transition] {
57536
57811
  /* The list needs a name to be a group at all; which name does not matter,
57537
57812
  only that no other element in the document carries it. */
57538
57813
  view-transition-name: match-element;
@@ -62691,6 +62966,8 @@ const PickerStyleCSSVars = {
62691
62966
  "borderWidth": "--picker-border-width",
62692
62967
  "borderRadius": "--picker-border-radius",
62693
62968
  "popoverMaxHeight": "--picker-popover-max-height",
62969
+ "dialogMinWidth": "--picker-dialog-min-width",
62970
+ "dialogMinHeight": "--picker-dialog-min-height",
62694
62971
  "dialogMaxWidth": "--picker-dialog-max-width",
62695
62972
  "dialogMaxHeight": "--picker-dialog-max-height",
62696
62973
  "popupBackgroundColor": "--picker-popup-background-color",
@@ -74034,10 +74311,11 @@ installImportMetaCssBuild(import.meta);/**
74034
74311
  * `navi-side`/`data-layer` attributes) rather than computed in JS — read
74035
74312
  * the CSS block below instead of expecting a JS equivalent of it here.
74036
74313
  *
74037
- * `anchorCustomEventDetail="ignore"` is required, not cosmetic, and in both
74038
- * modes: without it Popover docks next to whatever triggered the open instead
74039
- * of flush against the edge, and Dialog takes that trigger's width as its own
74040
- * `min-width` floor (`--anchor-width`), overriding the `width` prop.
74314
+ * `anchorCustomEventDetail="ignore"` is required, not cosmetic: without it
74315
+ * Popover docks next to whatever triggered the open instead of flush against
74316
+ * the edge. Dialog needs nothing here it never sizes itself from an anchor
74317
+ * unless asked to (`sizeFromAnchor`, see dialog.jsx), which a side panel
74318
+ * never does: its `width`/`height` props are what size it.
74041
74319
  */
74042
74320
  const css = /* css */`
74043
74321
  .navi_side_panel {