@jsenv/navi 0.29.74 → 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
@@ -30990,6 +31165,11 @@ const useDialogProps = props => {
30990
31165
  // handled generically by applyNewPosition itself (dispatches
30991
31166
  // navi_position_change on every call) — nothing to do here.
30992
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);
30993
31173
  positionDialog();
30994
31174
  if (sizing === "frozen") {
30995
31175
  // After positionDialog: the caps it writes
@@ -31012,6 +31192,12 @@ const useDialogProps = props => {
31012
31192
  event
31013
31193
  }) => {
31014
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
+ }
31015
31201
  }, {
31016
31202
  event: e,
31017
31203
  skipElementResize: true
@@ -31064,6 +31250,12 @@ const useDialogProps = props => {
31064
31250
  }
31065
31251
  const hasCssTransitionAnimation = Boolean(resolvedAnimation);
31066
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).
31067
31259
  const restoreFocus = openController.transferFocusOnOpen(dialogEl);
31068
31260
 
31069
31261
  // isModal outside-click detection (see this file's top comment for why
@@ -31129,9 +31321,16 @@ const useDialogProps = props => {
31129
31321
  // property is actually present — harmless the rest of the time.
31130
31322
  dialogEl.setAttribute("navi-hidden", "");
31131
31323
  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);
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);
31135
31334
  cancelOpenInteractionSuppression?.();
31136
31335
  if (hasCssTransitionAnimation) {
31137
31336
  suppressPointerEventsDuringTransition(dialogEl);
@@ -32443,6 +32642,12 @@ const usePopoverProps = props => {
32443
32642
  }
32444
32643
  popoverEl.removeAttribute("data-anchor-out-of-view");
32445
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
+ }
32446
32651
  }, {
32447
32652
  event: e,
32448
32653
  // it's ok for the popover to become unsync with the anchor size
@@ -38830,24 +39035,34 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
38830
39035
  // included).
38831
39036
 
38832
39037
  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] {
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] {
38837
39050
  view-transition-name: navi-route-transition;
38838
39051
  }
38839
39052
 
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
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
38848
39063
  warnAboutNamesEscapingArea). */
38849
39064
  @supports (view-transition-group: contain) {
38850
- [data-navi-route-transition-area] {
39065
+ :root[data-navi-route-transition] [data-navi-route-transition-area] {
38851
39066
  view-transition-group: contain;
38852
39067
  }
38853
39068
  }
@@ -45736,9 +45951,12 @@ installImportMetaCssBuild(import.meta);/**
45736
45951
  * along the bar it adds to the padding asked for. Note that every
45737
45952
  * `env(safe-area-inset-*)` is 0 unless the page asks for it:
45738
45953
  * `<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.
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.
45742
45960
  */
45743
45961
  const css$M = /* css */`
45744
45962
  @layer navi {
@@ -45798,39 +46016,50 @@ const css$M = /* css */`
45798
46016
  }
45799
46017
 
45800
46018
  /* 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. */
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. */
45803
46023
  &[data-area="top"] {
45804
46024
  top: var(--navi-app-inset-top);
45805
- 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
+ );
45806
46029
  padding-top: env(safe-area-inset-top);
45807
- box-shadow: 0 var(--navi-fixed-bar-border-width) 0
46030
+ border-bottom: var(--navi-fixed-bar-border-width) solid
45808
46031
  var(--navi-fixed-bar-border-color);
45809
46032
  }
45810
46033
  &[data-area="bottom"] {
45811
46034
  bottom: var(--navi-app-inset-bottom);
45812
- 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
+ );
45813
46039
  padding-bottom: env(safe-area-inset-bottom);
45814
- box-shadow: 0 calc(-1 * var(--navi-fixed-bar-border-width)) 0
46040
+ border-top: var(--navi-fixed-bar-border-width) solid
45815
46041
  var(--navi-fixed-bar-border-color);
45816
46042
  }
45817
46043
  &[data-area="left"] {
45818
46044
  left: var(--navi-app-inset-left);
45819
- 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
+ );
45820
46049
  padding-left: env(safe-area-inset-left);
45821
- box-shadow: var(--navi-fixed-bar-border-width) 0 0
46050
+ border-right: var(--navi-fixed-bar-border-width) solid
45822
46051
  var(--navi-fixed-bar-border-color);
45823
46052
  }
45824
46053
  &[data-area="right"] {
45825
46054
  right: var(--navi-app-inset-right);
45826
- 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
+ );
45827
46059
  padding-right: env(safe-area-inset-right);
45828
- box-shadow: calc(-1 * var(--navi-fixed-bar-border-width)) 0 0
46060
+ border-left: var(--navi-fixed-bar-border-width) solid
45829
46061
  var(--navi-fixed-bar-border-color);
45830
46062
  }
45831
- &[data-border="none"] {
45832
- box-shadow: none;
45833
- }
45834
46063
  }
45835
46064
  `;
45836
46065
  const FixedBarStyleCSSVars = {
@@ -45864,9 +46093,10 @@ const FixedBarStyleCSSVars = {
45864
46093
  * @param {string|number} [props.width] - …and for one on a side. The safe-area
45865
46094
  * inset is NOT part of it: it is added on top, so the content keeps the size
45866
46095
  * 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.
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.
45870
46100
  * @param {string|number} [props.maxWidth] - Keeps the bar lined up with a
45871
46101
  * content column narrower than the window (it stays centered).
45872
46102
  */
@@ -45879,9 +46109,16 @@ const FixedBar = ({
45879
46109
  import.meta.css = [css$M, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
45880
46110
  const defaultRef = useRef();
45881
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
+ }
45882
46118
  // Whichever of width/height crosses the edge the bar sits on is what the
45883
46119
  // 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
46120
+ // the size it was given, the inset of that edge, and the hairline standing
46121
+ // between it and the content. Measured rather than
45885
46122
  // rebuilt as a calc() expression, so a size coming from anywhere — a prop, a
45886
46123
  // theme variable, the content itself — is reserved just the same, and each
45887
46124
  // `env()` inset stays the browser's business alone.
@@ -45928,7 +46165,6 @@ const FixedBar = ({
45928
46165
  return jsx(Box, {
45929
46166
  baseClassName: "navi_fixed_bar",
45930
46167
  "data-area": area,
45931
- "data-border": border ? undefined : "none",
45932
46168
  ...props,
45933
46169
  styleCSSVars: FixedBarStyleCSSVars,
45934
46170
  children: children
@@ -57051,7 +57287,26 @@ const css$w = /* css */`
57051
57287
  flex-direction: column;
57052
57288
  background-color: var(--x-list-background-color);
57053
57289
  border: var(--x-list-border-width) solid var(--x-list-border-color);
57054
- 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
+ );
57055
57310
 
57056
57311
  transition: opacity 0.2s ease;
57057
57312
  /* overflow:hidden is required on the container (not the inner scroll element)
@@ -57060,6 +57315,13 @@ const css$w = /* css */`
57060
57315
  overflow: hidden;
57061
57316
 
57062
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
+
57063
57325
  width: inherit;
57064
57326
  min-width: inherit;
57065
57327
  max-width: var(--list-max-width, inherit);
@@ -57533,9 +57795,19 @@ const css$w = /* css */`
57533
57795
  without being contained animate across the page (the pictures live in the
57534
57796
  top layer, where no overflow of the document reaches them), which is worse
57535
57797
  than not animating at all. So a browser with no nested groups gets no name
57536
- 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. */
57537
57808
  @supports (view-transition-group: contain) {
57538
- .navi_list_container[data-item-transition] {
57809
+ :root:not([data-navi-route-transition], [data-navi-route-travel])
57810
+ .navi_list_container[data-item-transition] {
57539
57811
  /* The list needs a name to be a group at all; which name does not matter,
57540
57812
  only that no other element in the document carries it. */
57541
57813
  view-transition-name: match-element;