@jsenv/navi 0.29.74 → 0.29.76

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";
@@ -60,7 +60,9 @@ const css$12 = /* css */`
60
60
  what the page positioned itself, which loses to DOM order otherwise
61
61
  (a sticky part is written before what scrolls under it). Box applies
62
62
  it by default, isolated, and lets a call site write auto back:
63
- --box-header-z-index / --box-footer-z-index.
63
+ --box-header-z-index / --box-footer-z-index. <Box sticky> gets it from
64
+ the prop itself, for the same reason and with the same way out (an
65
+ explicit zIndex, "auto" included).
64
66
 
65
67
  "While stuck" is the condition the name states, and it costs something
66
68
  to ignore: a sticky part at rest is a block in the flow with nothing
@@ -16935,6 +16937,28 @@ const DIMENSION_PROPS = {
16935
16937
  return { transform: `scaleZ(${value})` };
16936
16938
  },
16937
16939
  };
16940
+ const applyPositionSticky = applyToCssPropWhenTruthy(
16941
+ "position",
16942
+ "sticky",
16943
+ "static",
16944
+ );
16945
+ // A sticky box is one something scrolls under, which is what
16946
+ // --navi-z-index-sticky names; without it the box is a positioned element at
16947
+ // z-index: auto and loses to anything the page raised — a Group member holding
16948
+ // focus (2) is seen passing in front of a sticky submit bar. Like Box's own
16949
+ // header/footer, the band applies always and not only while stuck: a box
16950
+ // written by an app is the generic case, it cannot read its own stuck state
16951
+ // (see docs/z_index.md), and dropping to auto loses to a single
16952
+ // position: relative. An explicit zIndex (including zIndex="auto") wins.
16953
+ const stickyZIndex = (styleContext) => {
16954
+ if (
16955
+ styleContext.styles.zIndex !== undefined ||
16956
+ styleContext.remainingProps.zIndex !== undefined
16957
+ ) {
16958
+ return null;
16959
+ }
16960
+ return { zIndex: "var(--navi-z-index-sticky)" };
16961
+ };
16938
16962
  const POSITION_PROPS = {
16939
16963
  // For row, selfAlignX uses auto margins for positioning
16940
16964
  // NOTE: Auto margins only work effectively for positioning individual items.
@@ -17002,11 +17026,22 @@ const POSITION_PROPS = {
17002
17026
  }
17003
17027
  return undefined;
17004
17028
  },
17005
- position: PASS_THROUGH,
17029
+ position: (value, styleContext) => {
17030
+ if (value === "sticky") {
17031
+ return { position: "sticky", ...stickyZIndex(styleContext) };
17032
+ }
17033
+ return { position: value };
17034
+ },
17006
17035
  absolute: applyToCssPropWhenTruthy("position", "absolute", "static"),
17007
17036
  relative: applyToCssPropWhenTruthy("position", "relative", "static"),
17008
17037
  fixed: applyToCssPropWhenTruthy("position", "fixed", "static"),
17009
- sticky: applyToCssPropWhenTruthy("position", "sticky", "static"),
17038
+ sticky: (value, styleContext) => {
17039
+ const positionStyles = applyPositionSticky(value, styleContext);
17040
+ if (!value) {
17041
+ return positionStyles;
17042
+ }
17043
+ return { ...positionStyles, ...stickyZIndex(styleContext) };
17044
+ },
17010
17045
  zIndex: PASS_THROUGH,
17011
17046
  // Keeps the zIndex values used inside this box local to it — see
17012
17047
  // docs/z_index.md: a z-index that opens no stacking context competes with
@@ -19345,10 +19380,48 @@ import.meta.css = [/* css */`
19345
19380
  outline-offset: calc(-1 * var(--navi-focus-outline-width));
19346
19381
  overflow: auto;
19347
19382
 
19383
+ /* The same reading as the header's corners above, on all four: a body
19384
+ follows the corners of the box it is drawn in — which is also what
19385
+ it clips its content to, the overflow just above. */
19386
+ border-top-left-radius: inherit;
19387
+ border-top-right-radius: inherit;
19388
+ border-bottom-right-radius: inherit;
19389
+ border-bottom-left-radius: inherit;
19390
+
19348
19391
  &:focus-visible {
19349
19392
  outline-style: solid;
19350
19393
  }
19351
19394
  }
19395
+
19396
+ /* A corner a header or a footer covers is not the body's to follow:
19397
+ what the body meets there is their flat separator line, not a curve,
19398
+ and a radius against it shows the box through the gap it opens. */
19399
+ > [data-header] ~ [data-body] {
19400
+ border-top-left-radius: 0;
19401
+ border-top-right-radius: 0;
19402
+ }
19403
+ > [data-body]:has(~ [data-footer]) {
19404
+ border-bottom-right-radius: 0;
19405
+ border-bottom-left-radius: 0;
19406
+ }
19407
+
19408
+ /* A body with no padding of its own holds content running edge to edge:
19409
+ whatever sits at one of its ends is drawn ON the corner the body just
19410
+ resolved, so a radius of its own there carves a notch out of it. The
19411
+ body already clips to that corner, which makes "none" the right radius
19412
+ for what lands on it — square, and the body draws the curve. The ask
19413
+ travels down as a corner claim (see group.jsx) so a navi control
19414
+ answers it wherever it sits inside. */
19415
+ > [data-body][data-body-flush] {
19416
+ > :first-child {
19417
+ --x-corner-top-left-radius: 0;
19418
+ --x-corner-top-right-radius: 0;
19419
+ }
19420
+ > :last-child {
19421
+ --x-corner-bottom-right-radius: 0;
19422
+ --x-corner-bottom-left-radius: 0;
19423
+ }
19424
+ }
19352
19425
  }
19353
19426
  }
19354
19427
 
@@ -19551,6 +19624,11 @@ const Box = props => {
19551
19624
  }
19552
19625
  if (body) {
19553
19626
  rest["data-body"] = "";
19627
+ // Padding is what decides whether the content reaches the body's own
19628
+ // corners — see the corner claims in this file's CSS.
19629
+ if (!PADDING_PROP_NAMES.some(name => isNonZeroSpacing(rest[name]))) {
19630
+ rest["data-body-flush"] = "";
19631
+ }
19554
19632
  }
19555
19633
  const defaultDisplay = getDefaultDisplay(TagName);
19556
19634
  // Read the parent flow early so we can use it when display="inherit" is requested.
@@ -20015,6 +20093,16 @@ const shouldInjectSeparatorBetween = (left, right) => {
20015
20093
  }
20016
20094
  return true;
20017
20095
  };
20096
+ const PADDING_PROP_NAMES = ["padding", "paddingX", "paddingY", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"];
20097
+ const isNonZeroSpacing = value => {
20098
+ if (value === undefined || value === null || value === false) {
20099
+ return false;
20100
+ }
20101
+ if (value === 0 || value === "0" || value === "none") {
20102
+ return false;
20103
+ }
20104
+ return true;
20105
+ };
20018
20106
 
20019
20107
  const useDebounceTrue = (value, delay = 300) => {
20020
20108
  const [debouncedTrue, setDebouncedTrue] = useState(false);
@@ -28546,7 +28634,21 @@ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
28546
28634
  focusedElement,
28547
28635
  focusVisible,
28548
28636
 
28549
- transferFocus: (transferEvent, containerEl) => {
28637
+ /**
28638
+ * Moves the focus into `containerEl`, on the element the ladder above
28639
+ * picks.
28640
+ *
28641
+ * `getDelay(target)` — asked once the target is known, answers how many
28642
+ * milliseconds to wait before actually focusing it. The ladder is what
28643
+ * decides WHO gets the focus and it may only run once (it consumes the
28644
+ * autofocus-restore mark), so a caller with a policy about WHEN cannot
28645
+ * resolve the target itself to make up its mind: it is handed the answer
28646
+ * instead. Returns a cancel function when it did delay, so a container
28647
+ * closing before the delay is up takes back a focus it never gave;
28648
+ * undefined when it focused straight away and there is nothing to take
28649
+ * back.
28650
+ */
28651
+ transferFocus: (transferEvent, containerEl, { getDelay } = {}) => {
28550
28652
  let target;
28551
28653
  let reason;
28552
28654
  const lastFocused = clearAutofocusRestore(containerEl);
@@ -28574,24 +28676,39 @@ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
28574
28676
  }
28575
28677
  }
28576
28678
  if (!target) {
28577
- return;
28679
+ return undefined;
28578
28680
  }
28579
28681
  // The modality speaks for the transfer, but an editable target outranks
28580
28682
  // it: it draws its ring on any focus (see isMatchingFocusVisible), so
28581
28683
  // the native :focus-visible is told the same.
28582
28684
  const targetFocusVisible = focusVisible || isEditableTarget(target);
28685
+ const giveFocus = () => {
28686
+ debugFocus(
28687
+ transferEvent,
28688
+ `Moving focus to ${getElementSignature(target)}.focus({ preventScroll: true, focusVisible: ${targetFocusVisible} }) (reason: ${reason})`,
28689
+ );
28690
+ target.focus({
28691
+ preventScroll: true,
28692
+ focusVisible: targetFocusVisible,
28693
+ });
28694
+ if (target.hasAttribute("navi-autofocus-select")) {
28695
+ target.select();
28696
+ target.scrollLeft = 0;
28697
+ }
28698
+ };
28699
+ const delay = getDelay?.(target) || 0;
28700
+ if (!delay) {
28701
+ giveFocus();
28702
+ return undefined;
28703
+ }
28583
28704
  debugFocus(
28584
28705
  transferEvent,
28585
- `Moving focus to ${getElementSignature(target)}.focus({ preventScroll: true, focusVisible: ${targetFocusVisible} }) (reason: ${reason})`,
28706
+ `Delaying focus to ${getElementSignature(target)} by ${delay}ms`,
28586
28707
  );
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
- }
28708
+ const timeout = setTimeout(giveFocus, delay);
28709
+ return () => {
28710
+ clearTimeout(timeout);
28711
+ };
28595
28712
  },
28596
28713
 
28597
28714
  restoreFocus: (restoreEvent) => {
@@ -28633,6 +28750,21 @@ const getFocusedBeforeTransfer = (e) => {
28633
28750
  return document.activeElement;
28634
28751
  };
28635
28752
 
28753
+ // How long a popup waits before handing the focus to a field, when giving it
28754
+ // is what raises the on-screen keyboard.
28755
+ //
28756
+ // The focus is normally given as early as possible. But a popup places itself
28757
+ // against the viewport, and on a phone the keyboard takes a third of that
28758
+ // viewport away the moment a field receives focus — so the two landing in the
28759
+ // same tick means the popup is still arriving when the room under it changes,
28760
+ // and it re-places itself mid-entrance. Waiting lets it settle first, and the
28761
+ // keyboard then shrinks a box that has stopped moving.
28762
+ //
28763
+ // Long enough to outlast an entrance transition rather than merely reaching
28764
+ // the next frame: what has to be over is the popup MOVING, not one paint of
28765
+ // it.
28766
+ const FOCUS_DELAY_ON_KEYBOARD_MS = 250;
28767
+
28636
28768
  /**
28637
28769
  * Owns open/close decision-making for a popup (Dialog or Popover): guards
28638
28770
  * against duplicate requests and notifies the popup owner's own reactions.
@@ -28871,8 +29003,32 @@ const createOpenController = (
28871
29003
  // once mousedown.preventDefault() has kept focus from landing
28872
29004
  // anywhere yet.
28873
29005
 
28874
- focusTransfer.transferFocus(e, el);
29006
+ // Two conditions, and both are about THIS opening rather than about
29007
+ // the device:
29008
+ // - the interaction: only a finger raises a virtual keyboard, and a
29009
+ // hybrid tablet answers "coarse" to every device-level signal
29010
+ // whichever of its two inputs was just used — the open event still
29011
+ // remembers which one it was. An opening with no pointer in it at
29012
+ // all (a keyboard shortcut, defaultOpen, an app calling open()) is
29013
+ // not one either.
29014
+ // - the target: focusing a button raises nothing, so there is nothing
29015
+ // to wait for and the focus stays immediate. Only a field the
29016
+ // keyboard comes up for is worth delaying — which is why the
29017
+ // decision is taken on the resolved target, inside transferFocus.
29018
+ const openedByTouch = Boolean(
29019
+ findEvent(requestOpenEvent, isTouchDrivenEvent),
29020
+ );
29021
+ const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
29022
+ getDelay: (target) =>
29023
+ openedByTouch && isEditableTarget(target)
29024
+ ? FOCUS_DELAY_ON_KEYBOARD_MS
29025
+ : 0,
29026
+ });
28875
29027
  return (closeEvent) => {
29028
+ // Closed before the delay was up: the focus was never given, so it
29029
+ // must not be given now — to a field inside a popup on its way out,
29030
+ // raising the keyboard as it goes.
29031
+ cancelPendingFocus?.();
28876
29032
  markAutofocusRestoreOnClose(el, closeEvent, focusedAtClose);
28877
29033
  const focusoutEvent = findEvent(closeEvent, "focusout");
28878
29034
  if (focusoutEvent) {
@@ -29141,6 +29297,60 @@ const flushSyncRendering = (fn) => {
29141
29297
  */
29142
29298
 
29143
29299
 
29300
+ /**
29301
+ * Whether a visibleRectEffect delivery is one that can have taken height away
29302
+ * from a popup, and so pushed whatever holds focus out of sight:
29303
+ * - "resize": the window/visual viewport settled — which is also how the
29304
+ * on-screen keyboard arrives, overlay or not (window_size.js);
29305
+ * - ELEMENT_SIZE_CHANGE: the popup's own box measured different;
29306
+ * - "focusin": the focus-settled re-measure, for the room that changes with
29307
+ * nothing announcing it (subscribeFocusSettled in window_size.js).
29308
+ *
29309
+ * Everything else is a scroll of one kind or another, where nothing got
29310
+ * smaller and scrolling the focused element back would fight the very gesture
29311
+ * that fired it.
29312
+ */
29313
+ const mayHaveHiddenFocus = (event) => {
29314
+ const type = event?.type;
29315
+ return (
29316
+ type === "resize" || type === ELEMENT_SIZE_CHANGE || type === "focusin"
29317
+ );
29318
+ };
29319
+
29320
+ /**
29321
+ * Scrolls whatever holds focus inside `popupEl` back into view, if the popup
29322
+ * getting shorter has pushed it out.
29323
+ *
29324
+ * The case this exists for: a field low in the scrolling body of a popup that
29325
+ * also has a footer (box.jsx — with a body, the body is the only thing that
29326
+ * scrolls and the footer is a sibling sitting right under it). Focusing the
29327
+ * field makes the browser scroll it into view, which it does against the
29328
+ * popup's height AT THAT MOMENT; the on-screen keyboard then opens and takes
29329
+ * that height away. The body shrinks, its scrollTop does not move, so the
29330
+ * content slides down relative to the shorter scrollport and the field ends up
29331
+ * past its bottom edge — visually, swallowed by the footer. The browser does
29332
+ * not redo a scroll-into-view it already answered, so this does.
29333
+ *
29334
+ * Scoped to the field's own scroll container (never the page): a popup traps
29335
+ * scrolling precisely so the document underneath cannot move, and a plain
29336
+ * scrollIntoView walks past a container whose scrollbar isn't visible — see
29337
+ * scrollIntoViewScoped's own doc.
29338
+ *
29339
+ * "nearest": the smallest scroll that makes it visible, and none at all when
29340
+ * it already is — so this is free to call on every resize, and never fights
29341
+ * where the user had scrolled to.
29342
+ */
29343
+ const keepFocusedElementVisible = (popupEl) => {
29344
+ const { activeElement } = document;
29345
+ if (!activeElement || activeElement === popupEl) {
29346
+ return;
29347
+ }
29348
+ if (!popupEl.contains(activeElement)) {
29349
+ return;
29350
+ }
29351
+ scrollIntoViewScoped(activeElement, { block: "nearest" });
29352
+ };
29353
+
29144
29354
  /**
29145
29355
  * Calls `onSettled` once `el`'s current CSS transition is over — via
29146
29356
  * `transitionend`, with a safety `setTimeout` fallback matching the longest
@@ -30044,6 +30254,24 @@ const css$X = /* css */`
30044
30254
  outline-color: var(--dialog-outline-color);
30045
30255
  outline-offset: 0;
30046
30256
  box-shadow: var(--dialog-box-shadow);
30257
+
30258
+ /* Docking answers a different question than --dialog-max-width: a sheet
30259
+ spans its container's full width, flush against the two side edges —
30260
+ that shape IS the mode — while the caller's ceiling was an answer about
30261
+ the *centered* box ("do not sprawl on a wide window"). Applying it here
30262
+ turns the sheet into a small floating box that no longer touches the
30263
+ edges it was docked to, so it is dropped out of the clamp entirely; the
30264
+ container ceiling still holds. --dialog-min-width needs no such rule:
30265
+ the floor is below the full width a docked dialog takes, so it stops
30266
+ mattering on its own. Height is untouched — a sheet is content-tall, not
30267
+ container-tall (expandY cancels docking outright), so --dialog-max-height
30268
+ still means what it meant. */
30269
+ &[data-docked] {
30270
+ --x-dialog-max-width: min(
30271
+ var(--container-position-remaining-width, var(--dialog-maxmax-width)),
30272
+ var(--dialog-maxmax-width)
30273
+ );
30274
+ }
30047
30275
  /* The clamped max, not --dialog-maxmax-*: that one is the viewport minus
30048
30276
  the spacing, which is only the real ceiling for layer="top". A local
30049
30277
  dialog is confined to its positioned ancestor, whose size reaches here
@@ -30284,7 +30512,15 @@ const css$X = /* css */`
30284
30512
  * from where the finger just tapped, and size alone would dock a narrow
30285
30513
  * desktop window, which is still a mouse. It supplies defaults for
30286
30514
  * `positionArea`, `marginWithContainer`, `expandX` and `scrollCapture`, so
30287
- * any of them can still be pinned explicitly. Ignored entirely when `expandY`
30515
+ * any of them can still be pinned explicitly including `expandX={false}`,
30516
+ * which opts the docked dialog out of the full-width stretch and leaves it a
30517
+ * floating box at the bottom. It also withdraws `maxWidth` while docked: a
30518
+ * sheet is container-wide by definition, and a `maxWidth` is an answer about
30519
+ * the *centered* shape, so the two can be stated together (`maxWidth="16rem"
30520
+ * dockedOnSmallTouchScreen`) and each applies where it means something.
30521
+ * `minWidth` needs no such rule — its floor is below the full width — and
30522
+ * `maxHeight`/`minHeight` keep applying, a sheet being content-tall.
30523
+ * Ignored entirely when `expandY`
30288
30524
  * (or `expand`) is set: a dialog already filling the height is on the bottom
30289
30525
  * edge docking would bring it to, so docking could only take away the shape
30290
30526
  * the caller asked for. Re-resolves live as the pointer
@@ -30306,7 +30542,10 @@ const css$X = /* css */`
30306
30542
  * @param {boolean} [props.expand] - Shorthand for both `expandX` and `expandY`.
30307
30543
  * @param {boolean} [props.expandX] - Stretches the dialog to the full width its
30308
30544
  * container allows (`--dialog-maxmax-width`). Set by
30309
- * `dockedOnSmallTouchScreen` on a small touch screen.
30545
+ * `dockedOnSmallTouchScreen` on a small touch screen — so passing `false`
30546
+ * here also opts out of *that* stretch, leaving a docked dialog a floating
30547
+ * box instead of a flush sheet. To keep the sheet flush and merely cap the
30548
+ * centered shape, use `maxWidth`: docking withdraws it on its own.
30310
30549
  * @param {boolean} [props.expandY] - Same, vertically
30311
30550
  * (`--dialog-maxmax-height`). Cancels `dockedOnSmallTouchScreen`.
30312
30551
  * @param {string|number} [props.marginWithContainer="3appw"] - Minimum gap kept
@@ -30365,7 +30604,9 @@ const css$X = /* css */`
30365
30604
  * so it can never push the dialog past `--dialog-maxmax-width` (the
30366
30605
  * viewport/container-spacing ceiling) regardless of how large a value is
30367
30606
  * passed.
30368
- * @param {string} [props.maxWidth] - Maps to `--dialog-max-width`.
30607
+ * @param {string} [props.maxWidth] - Maps to `--dialog-max-width`. Describes
30608
+ * the centered shape only: a dialog docked by `dockedOnSmallTouchScreen`
30609
+ * ignores it and stays container-wide.
30369
30610
  * @param {string} [props.minHeight] - Maps to `--dialog-min-height`, same
30370
30611
  * clamping as `minWidth`.
30371
30612
  * @param {string} [props.maxHeight] - Maps to `--dialog-max-height`.
@@ -30990,6 +31231,11 @@ const useDialogProps = props => {
30990
31231
  // handled generically by applyNewPosition itself (dispatches
30991
31232
  // navi_position_change on every call) — nothing to do here.
30992
31233
  };
31234
+ // Cleared here rather than on close, where the box is deliberately left
31235
+ // frozen at the size it was closing at (see the closing function's own
31236
+ // comment): this opening has its own content to be measured against, and
31237
+ // measuring it inside last time's box would answer with last time's size.
31238
+ unfreezeSize(dialogEl);
30993
31239
  positionDialog();
30994
31240
  if (sizing === "frozen") {
30995
31241
  // After positionDialog: the caps it writes
@@ -31012,6 +31258,12 @@ const useDialogProps = props => {
31012
31258
  event
31013
31259
  }) => {
31014
31260
  positionDialog(event);
31261
+ // Only for what can have taken height away from the dialog — a
31262
+ // scroll never does, and re-scrolling on one would fight the finger
31263
+ // that caused it. See keepFocusedElementVisible's own doc.
31264
+ if (mayHaveHiddenFocus(event)) {
31265
+ keepFocusedElementVisible(dialogEl);
31266
+ }
31015
31267
  }, {
31016
31268
  event: e,
31017
31269
  skipElementResize: true
@@ -31064,6 +31316,12 @@ const useDialogProps = props => {
31064
31316
  }
31065
31317
  const hasCssTransitionAnimation = Boolean(resolvedAnimation);
31066
31318
  const cancelOpenInteractionSuppression = !silent && hasCssTransitionAnimation ? suppressPointerEventsDuringTransition(dialogEl) : null;
31319
+ // Handing the focus to a field is what raises the on-screen keyboard, and
31320
+ // the keyboard takes away the very room this dialog was just placed
31321
+ // against — so on a touch-driven opening the transfer waits for the
31322
+ // entrance to be over. Decided by transferFocusOnOpen, the only place that
31323
+ // knows WHICH element is about to be focused (open_controller.js and its
31324
+ // FOCUS_DELAY_ON_KEYBOARD_MS).
31067
31325
  const restoreFocus = openController.transferFocusOnOpen(dialogEl);
31068
31326
 
31069
31327
  // isModal outside-click detection (see this file's top comment for why
@@ -31129,9 +31387,16 @@ const useDialogProps = props => {
31129
31387
  // property is actually present — harmless the rest of the time.
31130
31388
  dialogEl.setAttribute("navi-hidden", "");
31131
31389
  dialogEl.close();
31132
- // The freeze only ever holds for one opening: the next one has its own
31133
- // content to be measured against.
31134
- unfreezeSize(dialogEl);
31390
+ // Held at the size it has right now, for the whole way out. cleanup()
31391
+ // below already stops the JS repositioning, but the size is CSS-driven
31392
+ // (--x-dialog-max-height, and `height` outright under expandY) and
31393
+ // keeps following the visual viewport on its own — so a dialog closed
31394
+ // while the keyboard is up grows back to fill the room the keyboard is
31395
+ // giving back, WHILE fading out. Coherent, and still wrong to watch: a
31396
+ // box being dismissed has nothing left to adapt to, and the growth
31397
+ // reads as something happening at the exact moment nothing should. The
31398
+ // next opening clears it (see openEffect's own unfreezeSize).
31399
+ freezeSize(dialogEl);
31135
31400
  cancelOpenInteractionSuppression?.();
31136
31401
  if (hasCssTransitionAnimation) {
31137
31402
  suppressPointerEventsDuringTransition(dialogEl);
@@ -31259,6 +31524,10 @@ const useDialogProps = props => {
31259
31524
  // scrolling area, so it says so once, here.
31260
31525
  "overflow": "auto",
31261
31526
  "data-layer": layer,
31527
+ // The sheet shape is live in CSS, not just a set of resolved defaults:
31528
+ // it is what withdraws the caller's --dialog-max-width (see the stylesheet
31529
+ // above), which is an answer about the centered box only.
31530
+ "data-docked": isDocked ? "" : undefined,
31262
31531
  "data-expand-x": expandX ? "" : undefined,
31263
31532
  "data-expand-y": expandY ? "" : undefined,
31264
31533
  "data-flush-top": flushEdges.top ? "" : undefined,
@@ -32443,6 +32712,12 @@ const usePopoverProps = props => {
32443
32712
  }
32444
32713
  popoverEl.removeAttribute("data-anchor-out-of-view");
32445
32714
  positionPopover(event);
32715
+ // Same as Dialog's own — a popup with a scrolling body and a footer
32716
+ // swallows the field it just got shorter around. See
32717
+ // keepFocusedElementVisible's own doc in popup_shared.js.
32718
+ if (mayHaveHiddenFocus(event)) {
32719
+ keepFocusedElementVisible(popoverEl);
32720
+ }
32446
32721
  }, {
32447
32722
  event: e,
32448
32723
  // it's ok for the popover to become unsync with the anchor size
@@ -38830,24 +39105,34 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
38830
39105
  // included).
38831
39106
 
38832
39107
  const css$T = /* css */`
38833
- /* The marked region is a picture of its own during every view transition of
38834
- the documentwhich is what keeps it out of the root snapshot, where its
38835
- place would otherwise be blank. */
38836
- [data-navi-route-transition-area] {
39108
+ /* The marked region is a picture of its own for the length of a transition of
39109
+ OURS, and only then the name is what makes the pages a picture the
39110
+ movement below can carry.
39111
+
39112
+ Named outside that, it would be a picture during every view transition the
39113
+ APPLICATION starts — two rows swapping, a list changing — and a page is
39114
+ several screens tall: its picture is the whole element, drawn in the top
39115
+ layer from wherever the element starts, so it paints over the fixed bars
39116
+ and past the bottom of the screen for the length of a movement that has
39117
+ nothing to do with the pages. Unnamed, it stays part of the document's own
39118
+ picture, where the browser cuts it at the viewport like everything else. */
39119
+ :root[data-navi-route-transition] [data-navi-route-transition-area] {
38837
39120
  view-transition-name: navi-route-transition;
38838
39121
  }
38839
39122
 
38840
- /* A named descendant — a row named for a reorder gesture, a thumbnail named
38841
- for a morph — is a hole in the area's picture and a group of its own at the
38842
- top of the tree: it stands still and cross-fades on its own clock while the
38843
- pages move. Nested groups put it back INSIDE the area's picture, so it
38844
- travels with the pages and is cut at their edge. Said here rather than
38845
- erasing the name: a name inside the area is legitimate, and "contain" says
38846
- "these move with the page" where "none" would say "these do not exist". A
38847
- browser without nested groups is warned instead (see
39123
+ /* A named descendant — a thumbnail named for a morph, a row named for a
39124
+ reorder gesture — is a hole in the area's picture and a group of its own.
39125
+ Nested groups keep that group inside the area's, which is what cuts it at
39126
+ the pages' edge instead of letting it paint across the screen. What it does
39127
+ NOT do is make it travel: the movement is carried by the area's two
39128
+ pictures, and a group is not one of them, so a named descendant stands
39129
+ where it was captured while the pages slide under it. A morph wants exactly
39130
+ that; a component that names its parts for changes of its own does not, and
39131
+ drops its names for the length of the movement (see list.jsx). A browser
39132
+ without nested groups is warned instead (see
38848
39133
  warnAboutNamesEscapingArea). */
38849
39134
  @supports (view-transition-group: contain) {
38850
- [data-navi-route-transition-area] {
39135
+ :root[data-navi-route-transition] [data-navi-route-transition-area] {
38851
39136
  view-transition-group: contain;
38852
39137
  }
38853
39138
  }
@@ -45736,9 +46021,12 @@ installImportMetaCssBuild(import.meta);/**
45736
46021
  * along the bar it adds to the padding asked for. Note that every
45737
46022
  * `env(safe-area-inset-*)` is 0 unless the page asks for it:
45738
46023
  * `<meta name="viewport" content="…, viewport-fit=cover">`.
45739
- * 4. **Its hairline is a box-shadow, not a border.** A real border would eat
45740
- * into the size; a box-shadow draws the identical line and stays out of
45741
- * layout.
46024
+ * 4. **Its hairline is part of its box.** The line covers the content just as
46025
+ * the bar does, so the room given back has to include it a line drawn
46026
+ * outside the box (a box-shadow, an outline) is a line the content scrolls
46027
+ * under, and a line a page transition paints over. It is a real border,
46028
+ * added to the size asked for exactly like the notch inset is, so the
46029
+ * content still gets the size the prop names.
45742
46030
  */
45743
46031
  const css$M = /* css */`
45744
46032
  @layer navi {
@@ -45798,39 +46086,50 @@ const css$M = /* css */`
45798
46086
  }
45799
46087
 
45800
46088
  /* Across the bar, the inset of the edge it is pinned to is padding AND is
45801
- added to the size: the background then runs under the notch while the
45802
- content keeps the whole width/height asked for. */
46089
+ added to the size, and the hairline on the content side is added the
46090
+ same way: the background then runs under the notch, the line stands
46091
+ clear of the content, and the content keeps the whole width/height asked
46092
+ for. */
45803
46093
  &[data-area="top"] {
45804
46094
  top: var(--navi-app-inset-top);
45805
- height: calc(var(--navi-fixed-bar-height) + env(safe-area-inset-top));
46095
+ height: calc(
46096
+ var(--navi-fixed-bar-height) + env(safe-area-inset-top) +
46097
+ var(--navi-fixed-bar-border-width)
46098
+ );
45806
46099
  padding-top: env(safe-area-inset-top);
45807
- box-shadow: 0 var(--navi-fixed-bar-border-width) 0
46100
+ border-bottom: var(--navi-fixed-bar-border-width) solid
45808
46101
  var(--navi-fixed-bar-border-color);
45809
46102
  }
45810
46103
  &[data-area="bottom"] {
45811
46104
  bottom: var(--navi-app-inset-bottom);
45812
- height: calc(var(--navi-fixed-bar-height) + env(safe-area-inset-bottom));
46105
+ height: calc(
46106
+ var(--navi-fixed-bar-height) + env(safe-area-inset-bottom) +
46107
+ var(--navi-fixed-bar-border-width)
46108
+ );
45813
46109
  padding-bottom: env(safe-area-inset-bottom);
45814
- box-shadow: 0 calc(-1 * var(--navi-fixed-bar-border-width)) 0
46110
+ border-top: var(--navi-fixed-bar-border-width) solid
45815
46111
  var(--navi-fixed-bar-border-color);
45816
46112
  }
45817
46113
  &[data-area="left"] {
45818
46114
  left: var(--navi-app-inset-left);
45819
- width: calc(var(--navi-fixed-bar-width) + env(safe-area-inset-left));
46115
+ width: calc(
46116
+ var(--navi-fixed-bar-width) + env(safe-area-inset-left) +
46117
+ var(--navi-fixed-bar-border-width)
46118
+ );
45820
46119
  padding-left: env(safe-area-inset-left);
45821
- box-shadow: var(--navi-fixed-bar-border-width) 0 0
46120
+ border-right: var(--navi-fixed-bar-border-width) solid
45822
46121
  var(--navi-fixed-bar-border-color);
45823
46122
  }
45824
46123
  &[data-area="right"] {
45825
46124
  right: var(--navi-app-inset-right);
45826
- width: calc(var(--navi-fixed-bar-width) + env(safe-area-inset-right));
46125
+ width: calc(
46126
+ var(--navi-fixed-bar-width) + env(safe-area-inset-right) +
46127
+ var(--navi-fixed-bar-border-width)
46128
+ );
45827
46129
  padding-right: env(safe-area-inset-right);
45828
- box-shadow: calc(-1 * var(--navi-fixed-bar-border-width)) 0 0
46130
+ border-left: var(--navi-fixed-bar-border-width) solid
45829
46131
  var(--navi-fixed-bar-border-color);
45830
46132
  }
45831
- &[data-border="none"] {
45832
- box-shadow: none;
45833
- }
45834
46133
  }
45835
46134
  `;
45836
46135
  const FixedBarStyleCSSVars = {
@@ -45864,9 +46163,10 @@ const FixedBarStyleCSSVars = {
45864
46163
  * @param {string|number} [props.width] - …and for one on a side. The safe-area
45865
46164
  * inset is NOT part of it: it is added on top, so the content keeps the size
45866
46165
  * asked for.
45867
- * @param {boolean} [props.border=true] - The hairline on the content side.
45868
- * Drawn with a box-shadow so it never eats into the size; give it a
45869
- * `borderWidth`/`borderColor`, or `border={false}` for none.
46166
+ * @param {boolean} [props.border=true] - The hairline on the content side. It
46167
+ * is added to the size rather than taken out of it, and counts in the room
46168
+ * the bar gives back; give it a `borderWidth`/`borderColor`, or
46169
+ * `border={false}` for none.
45870
46170
  * @param {string|number} [props.maxWidth] - Keeps the bar lined up with a
45871
46171
  * content column narrower than the window (it stays centered).
45872
46172
  */
@@ -45879,9 +46179,16 @@ const FixedBar = ({
45879
46179
  import.meta.css = [css$M, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
45880
46180
  const defaultRef = useRef();
45881
46181
  props.ref = props.ref || defaultRef;
46182
+ // Said with the width the border rule reads rather than with an attribute of
46183
+ // its own: the width is what the size calc adds, so a line asked away here
46184
+ // is a line that takes no room either.
46185
+ if (!border) {
46186
+ props.borderWidth = "0px";
46187
+ }
45882
46188
  // Whichever of width/height crosses the edge the bar sits on is what the
45883
46189
  // content has to be given back — and the bar's border box already IS that:
45884
- // the size it was given plus the inset of that edge. Measured rather than
46190
+ // the size it was given, the inset of that edge, and the hairline standing
46191
+ // between it and the content. Measured rather than
45885
46192
  // rebuilt as a calc() expression, so a size coming from anywhere — a prop, a
45886
46193
  // theme variable, the content itself — is reserved just the same, and each
45887
46194
  // `env()` inset stays the browser's business alone.
@@ -45928,7 +46235,6 @@ const FixedBar = ({
45928
46235
  return jsx(Box, {
45929
46236
  baseClassName: "navi_fixed_bar",
45930
46237
  "data-area": area,
45931
- "data-border": border ? undefined : "none",
45932
46238
  ...props,
45933
46239
  styleCSSVars: FixedBarStyleCSSVars,
45934
46240
  children: children
@@ -57051,7 +57357,26 @@ const css$w = /* css */`
57051
57357
  flex-direction: column;
57052
57358
  background-color: var(--x-list-background-color);
57053
57359
  border: var(--x-list-border-width) solid var(--x-list-border-color);
57054
- border-radius: var(--x-list-border-radius);
57360
+ /* Squared from the outside, corner by corner: whoever draws the surface
57361
+ the list is laid on says which corners are the list's to draw (a popup's
57362
+ body does, see box.jsx), and each corner falls back to the list's own
57363
+ radius when nothing asks for anything. */
57364
+ border-top-left-radius: var(
57365
+ --x-corner-top-left-radius,
57366
+ var(--x-list-border-radius)
57367
+ );
57368
+ border-top-right-radius: var(
57369
+ --x-corner-top-right-radius,
57370
+ var(--x-list-border-radius)
57371
+ );
57372
+ border-bottom-right-radius: var(
57373
+ --x-corner-bottom-right-radius,
57374
+ var(--x-list-border-radius)
57375
+ );
57376
+ border-bottom-left-radius: var(
57377
+ --x-corner-bottom-left-radius,
57378
+ var(--x-list-border-radius)
57379
+ );
57055
57380
 
57056
57381
  transition: opacity 0.2s ease;
57057
57382
  /* overflow:hidden is required on the container (not the inner scroll element)
@@ -57060,6 +57385,13 @@ const css$w = /* css */`
57060
57385
  overflow: hidden;
57061
57386
 
57062
57387
  .navi_list_scroll_container {
57388
+ /* The ask stops here: this element is inside the list's frame, so a row
57389
+ or a control it holds is not at the surface's corner. */
57390
+ --x-corner-top-left-radius: initial;
57391
+ --x-corner-top-right-radius: initial;
57392
+ --x-corner-bottom-right-radius: initial;
57393
+ --x-corner-bottom-left-radius: initial;
57394
+
57063
57395
  width: inherit;
57064
57396
  min-width: inherit;
57065
57397
  max-width: var(--list-max-width, inherit);
@@ -57533,9 +57865,19 @@ const css$w = /* css */`
57533
57865
  without being contained animate across the page (the pictures live in the
57534
57866
  top layer, where no overflow of the document reaches them), which is worse
57535
57867
  than not animating at all. So a browser with no nested groups gets no name
57536
- either, and the change simply happens. */
57868
+ either, and the change simply happens.
57869
+
57870
+ Named for a change of the list's own, and for that alone: while the PAGES
57871
+ are the ones moving — a route transition, a route travel — the list is part
57872
+ of what travels, and a picture of its own is precisely what does not
57873
+ travel. A page is carried by its own picture; anything named inside it is
57874
+ lifted out of that picture into a group of its own, which stays where it
57875
+ was captured while the page slides away under it. So the names are dropped
57876
+ for the length of such a movement and the list crosses the screen with the
57877
+ page, as a block. */
57537
57878
  @supports (view-transition-group: contain) {
57538
- .navi_list_container[data-item-transition] {
57879
+ :root:not([data-navi-route-transition], [data-navi-route-travel])
57880
+ .navi_list_container[data-item-transition] {
57539
57881
  /* The list needs a name to be a group at all; which name does not matter,
57540
57882
  only that no other element in the document carries it. */
57541
57883
  view-transition-name: match-element;
@@ -65595,6 +65937,8 @@ const css$l = /* css */`
65595
65937
  * positionArea?: string,
65596
65938
  * popupWidthFitContent?: boolean,
65597
65939
  * popoverMaxHeight?: number | string,
65940
+ * dialogMinWidth?: number | string,
65941
+ * dialogMinHeight?: number | string,
65598
65942
  * dialogMaxWidth?: number | string,
65599
65943
  * dialogMaxHeight?: number | string,
65600
65944
  * dialogExpand?: boolean,
@@ -65644,7 +65988,8 @@ const css$l = /* css */`
65644
65988
  * popover; a dialog keeps Dialog's own "center".
65645
65989
  *
65646
65990
  * Every other prop the Picker's popup answers to is forwarded as-is —
65647
- * `dockedOnSmallTouchScreen`, `dialogExpand*`, `dialogMaxWidth`/`Height`,
65991
+ * `dockedOnSmallTouchScreen`, `dialogExpand*`, `dialogMinWidth`/`Height`,
65992
+ * `dialogMaxWidth`/`Height`,
65648
65993
  * `marginWithContainer`, `popoverMode`, `popoverSpacing`, `popupLayer`,
65649
65994
  * `popupWidthFitContent`, `popoverMaxHeight`, `backdropVariant`,
65650
65995
  * `pointerInteractionOutsideEffect`, `escapeEffect`, `closeOnFocusOut`,
@@ -65849,7 +66194,7 @@ const SplitButton = props => {
65849
66194
  // What the Picker's popup answers to — Picker's own popup props, named here so
65850
66195
  // a caller reaches all of them through the split button (see picker.jsx's JSDoc
65851
66196
  // for what each one says).
65852
- const POPUP_PROP_SET = new Set(["mode", "popupLayer", "positionArea", "popoverMode", "popoverSpacing", "popupWidthFitContent", "popoverMaxHeight", "dialogMaxWidth", "dialogMaxHeight", "dialogExpand", "dialogExpandX", "dialogExpandY", "dockedOnSmallTouchScreen", "marginWithContainer", "backdropVariant", "pointerInteractionOutsideEffect", "escapeEffect", "closeOnFocusOut", "scrollCapture", "focusCapture", "popupBackgroundColor", "popupBorderRadius", "animation"]);
66197
+ const POPUP_PROP_SET = new Set(["mode", "popupLayer", "positionArea", "popoverMode", "popoverSpacing", "popupWidthFitContent", "popoverMaxHeight", "dialogMinWidth", "dialogMinHeight", "dialogMaxWidth", "dialogMaxHeight", "dialogExpand", "dialogExpandX", "dialogExpandY", "dockedOnSmallTouchScreen", "marginWithContainer", "backdropVariant", "pointerInteractionOutsideEffect", "escapeEffect", "closeOnFocusOut", "scrollCapture", "focusCapture", "popupBackgroundColor", "popupBorderRadius", "animation"]);
65853
66198
  const splitPopupProps = props => {
65854
66199
  const popupProps = {};
65855
66200
  const boxProps = {};