@jsenv/navi 0.30.35 → 0.30.36

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.
@@ -75472,18 +75472,19 @@ const readRoom = () =>
75472
75472
  visualViewportHeightSignal.peek() - getVirtualKeyboardOverlayHeight();
75473
75473
 
75474
75474
  /**
75475
- * Pushing a popup docked to an edge (a side panel, a bottom sheet) back the way
75476
- * it came in closes it: it follows the finger while it travels, and letting go
75477
- * either finishes the departure or brings it back to rest — whichever the
75478
- * travel was already heading to.
75475
+ * Pushing a popup docked to an edge (a side panel, a bottom sheet, a toast)
75476
+ * back the way it came in closes it: it follows the finger while it travels,
75477
+ * and letting go either finishes the departure or brings it back to rest —
75478
+ * whichever the travel was already heading to. A popup may be given other ways
75479
+ * out than that one: a toast is also thrown sideways.
75479
75480
  *
75480
75481
  * Reading the pointer is not done here: when a press becomes a gesture, which
75481
75482
  * axis it leans on, how fast it was going when it was let go, who else has a
75482
75483
  * claim on it (a field, a scroller with room left, a box inside the panel that
75483
75484
  * travels the same way) is one gesture system for the whole codebase
75484
75485
  * (@jsenv/dom's startDragToTravel). A panel closing is a travel of exactly one
75485
- * box, towards the edge it is docked to and nowhere else — which is all this
75486
- * file has to say.
75486
+ * box, out by one of the edges it may leave by and nowhere else — which is all
75487
+ * this file has to say.
75487
75488
  *
75488
75489
  * The release travel is driven here (Web Animations) rather than handed to the
75489
75490
  * exit transition the `animation` prop may install: its pace is what is left to
@@ -75498,7 +75499,7 @@ const readRoom = () =>
75498
75499
  // less, so the panel keeps the same speed whenever the finger let go.
75499
75500
  const TRAVEL_DURATION = 220;
75500
75501
 
75501
- // Which way a popup docked to each side travels — read by the panel itself to
75502
+ // Which way a popup leaving by each side travels — read by the panel itself to
75502
75503
  // say so in the DOM, which is how a box travelling INSIDE it (a row of slides,
75503
75504
  // a route travel) knows that axis is already walked.
75504
75505
  const SWIPE_AXIS_BY_SIDE = {
@@ -75507,12 +75508,27 @@ const SWIPE_AXIS_BY_SIDE = {
75507
75508
  top: "y",
75508
75509
  bottom: "y",
75509
75510
  };
75510
- // Which way the finger pushes to send the panel back where it came from, in
75511
- // screen coordinates: positive is right/down, the same sign the gesture reports.
75511
+ // Which way the finger pushes to send the panel out by each side, in screen
75512
+ // coordinates: positive is right/down, the same sign the gesture reports.
75512
75513
  const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
75513
75514
 
75514
75515
  /**
75515
- * Builds the `pointerdown` handler a popup docked to `side` answers with.
75516
+ * The axes a popup leaving by `sides` travels on, as the gesture and the DOM
75517
+ * say them: "x", "y" or "xy".
75518
+ */
75519
+ const swipeAxesOf = (sides) => {
75520
+ const hasX = sides.some((side) => SWIPE_AXIS_BY_SIDE[side] === "x");
75521
+ const hasY = sides.some((side) => SWIPE_AXIS_BY_SIDE[side] === "y");
75522
+ if (hasX && hasY) {
75523
+ return "xy";
75524
+ }
75525
+ return hasX ? "x" : "y";
75526
+ };
75527
+
75528
+ /**
75529
+ * Builds the `pointerdown` handler of a popup that leaves by any of `sides`:
75530
+ * usually the one edge it is docked to, the way it came in; a toast is also
75531
+ * thrown sideways, and leaves the way it was thrown.
75516
75532
  *
75517
75533
  * `grip` says where the gesture may start, as a selector the pressed element is
75518
75534
  * matched against with `closest`. A popup that names one is held THERE and
@@ -75520,11 +75536,17 @@ const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
75520
75536
  * and a press on it is that content's — including gestures of its own, which
75521
75537
  * this file has no way of knowing about. A popup that names no grip is pushed
75522
75538
  * from its whole surface, which only suits one made of nothing else (a side
75523
- * panel showing a page).
75524
- */
75525
- const createSwipeToClose = (side, { grip } = {}) => {
75526
- const axis = SWIPE_AXIS_BY_SIDE[side];
75527
- const closeDirection = CLOSE_DIRECTION_BY_SIDE[side];
75539
+ * panel showing a page, a toast).
75540
+ */
75541
+ const createSwipeToClose = (sides, { grip } = {}) => {
75542
+ const axes = swipeAxesOf(sides);
75543
+ // The side a push along `axis` towards `sign` leaves by, if it leaves at all.
75544
+ const sideTowards = (axis, sign) =>
75545
+ sides.find(
75546
+ (side) =>
75547
+ SWIPE_AXIS_BY_SIDE[side] === axis &&
75548
+ CLOSE_DIRECTION_BY_SIDE[side] === sign,
75549
+ );
75528
75550
 
75529
75551
  return (pointerDownEvent) => {
75530
75552
  const panelEl = pointerDownEvent.currentTarget;
@@ -75541,6 +75563,11 @@ const createSwipeToClose = (side, { grip } = {}) => {
75541
75563
  }
75542
75564
  }
75543
75565
 
75566
+ // Decided when the finger picks its axis (onStart), once for the gesture.
75567
+ let axis;
75568
+ // How far the panel travels to be out, for each way it can go on that axis.
75569
+ const distanceOutBySign = new Map();
75570
+
75544
75571
  // How far the panel has been pulled, as the style showing it — written on
75545
75572
  // the panel while the finger drives it, handed to the release travel as
75546
75573
  // its two keyframes. The gesture reports a distance in screen coordinates,
@@ -75550,7 +75577,7 @@ const createSwipeToClose = (side, { grip } = {}) => {
75550
75577
  // composes under the placement instead of replacing it.
75551
75578
  const styleAt = (distance) => ({
75552
75579
  transform: translateOf(axis, distance),
75553
- clipPath: clipOf(side, distance),
75580
+ clipPath: clipOf(sides, axis, distance),
75554
75581
  });
75555
75582
  const paint = (distance) => {
75556
75583
  Object.assign(panelEl.style, styleAt(distance));
@@ -75562,21 +75589,22 @@ const createSwipeToClose = (side, { grip } = {}) => {
75562
75589
  };
75563
75590
  // The resting value is written first and the animation only covers the way
75564
75591
  // to it: both end on the same number, so nothing is seen changing when the
75565
- // animation hands the panel back to its own style.
75566
- const travelTo = (from, to, onArrival) => {
75592
+ // animation hands the panel back to its own style. `span` is the full travel
75593
+ // on that side, which the duration is a fraction of.
75594
+ const travelTo = (from, to, span, onArrival) => {
75567
75595
  const covered = from > to ? from - to : to - from;
75568
75596
  paint(to);
75569
75597
  const animation = panelEl.animate([styleAt(from), styleAt(to)], {
75570
- duration: (covered / sizeOf(panelEl, axis)) * TRAVEL_DURATION,
75598
+ duration: (covered / span) * TRAVEL_DURATION,
75571
75599
  easing: "ease-out",
75572
75600
  });
75573
75601
  animation.finished.then(onArrival, () => {});
75574
75602
  };
75575
- const close = (event) => {
75603
+ const close = (event, out, span) => {
75576
75604
  triggerNaviCommand(panelEl, "--navi-close", event);
75577
75605
  if (panelEl.getAttribute("aria-expanded") === "true") {
75578
75606
  // Refused: the panel is staying, so it goes back where it was.
75579
- travelTo(sizeOf(panelEl, axis) * closeDirection, 0, restore);
75607
+ travelTo(out, 0, span, restore);
75580
75608
  return;
75581
75609
  }
75582
75610
  restore();
@@ -75584,79 +75612,94 @@ const createSwipeToClose = (side, { grip } = {}) => {
75584
75612
 
75585
75613
  startDragToTravel(pointerDownEvent, {
75586
75614
  element: panelEl,
75587
- axes: axis,
75588
- onStart: ({ sign, target }) => {
75589
- if (sign !== closeDirection) {
75615
+ axes,
75616
+ onStart: ({ axis: gestureAxis, sign, target }) => {
75617
+ const side = sideTowards(gestureAxis, sign);
75618
+ if (!side) {
75590
75619
  // Pushing the panel further in is not a travel: there is nowhere that
75591
75620
  // way, and reading it as one would make the panel lean at every
75592
75621
  // gesture that starts by going the wrong way.
75593
75622
  return false;
75594
75623
  }
75595
- const size = sizeOf(panelEl, axis);
75624
+ const size = distanceOut(panelEl, side);
75596
75625
  if (!size) {
75597
75626
  return false;
75598
75627
  }
75599
75628
  // Something else with a better claim on the gesture: a scroller between
75600
75629
  // the finger and the panel, with room left that way.
75601
- if (scrollRoomTowards(target, panelEl, axis, sign)) {
75630
+ if (scrollRoomTowards(target, panelEl, gestureAxis, sign)) {
75602
75631
  return false;
75603
75632
  }
75633
+ axis = gestureAxis;
75634
+ distanceOutBySign.set(sign, size);
75635
+ const otherSide = sideTowards(gestureAxis, -sign);
75636
+ if (otherSide) {
75637
+ distanceOutBySign.set(-sign, distanceOut(panelEl, otherSide));
75638
+ }
75604
75639
  panelEl.style.transitionProperty = "none";
75605
- // The way out is the only way there is anything: pulling the other way
75606
- // leans against a wall and comes back.
75640
+ // Only the ways out lead anywhere: pulling towards a side the panel
75641
+ // does not leave by leans against a wall and comes back.
75607
75642
  return {
75608
75643
  size,
75609
- travelBack: closeDirection > 0,
75610
- travelOn: closeDirection < 0,
75644
+ travelBack: Boolean(sideTowards(gestureAxis, 1)),
75645
+ travelOn: Boolean(sideTowards(gestureAxis, -1)),
75611
75646
  };
75612
75647
  },
75613
75648
  onPull: ({ pulled }) => {
75614
75649
  paint(pulled);
75615
75650
  },
75616
- onEnd: ({ pulled, size, travels, event }) => {
75651
+ onEnd: ({ pulled, size, sign, travels, event }) => {
75617
75652
  if (travels) {
75618
- travelTo(pulled, size * closeDirection, () => close(event));
75653
+ const span = distanceOutBySign.get(sign);
75654
+ travelTo(pulled, span * sign, span, () =>
75655
+ close(event, span * sign, span),
75656
+ );
75619
75657
  return;
75620
75658
  }
75621
- travelTo(pulled, 0, restore);
75659
+ travelTo(pulled, 0, size, restore);
75622
75660
  },
75623
75661
  });
75624
75662
  };
75625
75663
  };
75626
75664
 
75627
- const sizeOf = (element, axis) => {
75665
+ // What takes the popup out of sight: its own size, plus the gap a margin keeps
75666
+ // between it and the edge it is pushed through — the room applyNewPosition
75667
+ // publishes on that side. Travelling its size alone, a popup kept off its edge
75668
+ // (a toast 12px above a bar) would stop with that much of itself still showing.
75669
+ const distanceOut = (element, side) => {
75628
75670
  const rect = element.getBoundingClientRect();
75629
- return axis === "x" ? rect.width : rect.height;
75671
+ const size = SWIPE_AXIS_BY_SIDE[side] === "x" ? rect.width : rect.height;
75672
+ const room = parseFloat(
75673
+ element.style.getPropertyValue(`--container-position-room-${side}`),
75674
+ );
75675
+ return Number.isNaN(room) ? size : size + room;
75630
75676
  };
75631
75677
  const translateOf = (axis, distance) =>
75632
75678
  axis === "x"
75633
75679
  ? `translate(${distance}px, 0px)`
75634
75680
  : `translate(0px, ${distance}px)`;
75635
75681
  // The same cut the entry/exit animation makes (popup_css.js), at a distance the
75636
- // finger decides instead of a transition: the edge the popup is pushed back
75637
- // through, as popup_css.js placed it (--x-popup-cut-*-at-area — on the area's
75638
- // edge where a band of glass lies past it, 100vmax out where the window's own
75639
- // edge does the cutting), plus what has been pulled, so the cut holds that line
75640
- // while the box travels under it. The three other sides are left far outside
75641
- // the box — the pull never takes the popup past them, and cutting there would
75642
- // only shave what it legitimately paints outside its own box. Reading the
75643
- // policy from popup_css.js rather than the rooms keeps the release travel
75644
- // (a Web Animation on transform AND clip-path, two clocks) from animating a
75645
- // cut the entrance would not.
75682
+ // finger decides instead of a transition: each edge the popup may leave by on
75683
+ // the axis it travels, as popup_css.js placed it (--x-popup-cut-*-at-area — on
75684
+ // the area's edge where a band of glass lies past it, 100vmax out where the
75685
+ // window's own edge does the cutting), moved by what has been pulled towards
75686
+ // it, so the cut holds that line while the box travels under it, whichever way.
75687
+ // Every other side is left far outside the box — the pull never takes the popup
75688
+ // past them, and cutting there would only shave what it legitimately paints
75689
+ // outside its own box. Reading the policy from popup_css.js rather than the
75690
+ // rooms keeps the release travel (a Web Animation on transform AND clip-path,
75691
+ // two clocks) from animating a cut the entrance would not.
75646
75692
  const UNCUT = "-100vmax";
75647
- const clipOf = (side, distance) => {
75648
- const pulled = distance < 0 ? -distance : distance;
75649
- const cut = `calc(var(--x-popup-cut-${side}-at-area, ${UNCUT}) + ${pulled}px)`;
75650
- if (side === "top") {
75651
- return `inset(${cut} ${UNCUT} ${UNCUT} ${UNCUT})`;
75652
- }
75653
- if (side === "right") {
75654
- return `inset(${UNCUT} ${cut} ${UNCUT} ${UNCUT})`;
75655
- }
75656
- if (side === "bottom") {
75657
- return `inset(${UNCUT} ${UNCUT} ${cut} ${UNCUT})`;
75658
- }
75659
- return `inset(${UNCUT} ${UNCUT} ${UNCUT} ${cut})`;
75693
+ const INSET_ORDER = ["top", "right", "bottom", "left"];
75694
+ const clipOf = (sides, axis, distance) => {
75695
+ const insets = INSET_ORDER.map((side) => {
75696
+ if (!sides.includes(side) || SWIPE_AXIS_BY_SIDE[side] !== axis) {
75697
+ return UNCUT;
75698
+ }
75699
+ const towards = distance * CLOSE_DIRECTION_BY_SIDE[side];
75700
+ return `calc(var(--x-popup-cut-${side}-at-area, ${UNCUT}) + ${towards}px)`;
75701
+ });
75702
+ return `inset(${insets.join(" ")})`;
75660
75703
  };
75661
75704
 
75662
75705
  /**
@@ -77438,7 +77481,7 @@ const useDialogProps = props => {
77438
77481
  // actually resting on an edge: anywhere else the gesture would send the
77439
77482
  // dialog somewhere it never came from.
77440
77483
  const swipeToCloseSide = isDocked && (parsedPositionArea.y === "top" || parsedPositionArea.y === "bottom") ? parsedPositionArea.y : null;
77441
- const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose(swipeToCloseSide, {
77484
+ const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose([swipeToCloseSide], {
77442
77485
  grip: DOCKED_SWIPE_GRIP
77443
77486
  }) : null;
77444
77487
  // A corner sitting exactly on the container's own corner must not be
@@ -78531,6 +78574,16 @@ const css$C = /* css */`
78531
78574
  to filter). An entrance animation moves the popover through scale and
78532
78575
  transform instead, which compose under it: see popup_css.js. */
78533
78576
 
78577
+ /* What a touch may do on a popover pushed sideways to close it (see
78578
+ swipe_to_close.js): the push takes the sideways axis, the page keeps the
78579
+ vertical scroll. A push up or down travels the way the page scrolls and
78580
+ cannot give that axis away: the browser keeps it, and the gesture is
78581
+ settled by keepTouchRefusable. */
78582
+ &[data-swipe-to-close~="left"],
78583
+ &[data-swipe-to-close~="right"] {
78584
+ touch-action: pan-y;
78585
+ }
78586
+
78534
78587
  /* The clamped max, not --popover-maxmax-*: that one ignores what the
78535
78588
  placement left available, which reaches here through
78536
78589
  --container-position-remaining-* (see applyNewPosition) — the min()
@@ -78761,6 +78814,20 @@ const css$C = /* css */`
78761
78814
  * overlap with the anchor; wrap it in `inset(...)` (e.g. `inset(top)`,
78762
78815
  * `inset(top-left)`) to overlap the anchor instead (edges touching or
78763
78816
  * inside it).
78817
+ * @param {boolean|Array<"top"|"right"|"bottom"|"left">} [props.closeByDrag=false] -
78818
+ * Pushing the popover out of its container closes it: it follows the pointer
78819
+ * and, when released, finishes leaving or comes back to rest. `true` pushes
78820
+ * it back through the edge it is docked to, the word `positionArea` leads
78821
+ * with (`"bottom"`, `"bottom-start"`: pushed down; `"left-end"`: pushed
78822
+ * left); a centred popover rests on no edge and is not pushed (warned in
78823
+ * dev). A list names the sides it may leave by, and it leaves the way it was
78824
+ * pushed: `["bottom", "left", "right"]` for a toast pushed back down behind
78825
+ * the bar or thrown sideways. A popover placed against an anchor at its
78826
+ * opening is not pushed either way (warned in dev). It is pushed from its
78827
+ * whole surface, so it suits a popover holding little else: a button inside
78828
+ * keeps its tap, and a press that turns into a push swallows the click it
78829
+ * would have left. The close keeps, as a cross's does. See docs/interactions.md
78830
+ * for a swipe on the popover's own element, which does not move it.
78764
78831
  * @param {string} [props.positionAreaFixed] - Overrides `positionArea` once
78765
78832
  * the popover has actually been positioned once, so a live reposition
78766
78833
  * (e.g. anchor moved) doesn't jump to a different side.
@@ -79212,6 +79279,7 @@ const usePopoverProps = props => {
79212
79279
  positionAreaWhenAnchorIsInvalid,
79213
79280
  marginWithContainer = 0,
79214
79281
  container = "app",
79282
+ closeByDrag = false,
79215
79283
  // "close" → pointer press outside closes (a plain close = commit)
79216
79284
  // "cancel" → pointer press outside closes AS A CANCEL (revert)
79217
79285
  // "capture"→ absorb the press, stay open
@@ -79339,6 +79407,7 @@ const usePopoverProps = props => {
79339
79407
  return popupGroup.register(openController);
79340
79408
  }, [popupGroup, openController]);
79341
79409
  const hasBackdrop = pressOutside !== "ignore" && backdrop && !popupGroup;
79410
+ const swipeToCloseSides = resolveSwipeToCloseSides(closeByDrag, positionArea);
79342
79411
  if (!popupGroup && !backdrop && pressOutside === "capture") {
79343
79412
  console.warn(`Popover: pressOutside="capture" needs a backdrop. Absorbing a press is what a wall does, and backdrop={false} takes it away — every press outside reaches the page, exactly as with "ignore".`);
79344
79413
  }
@@ -80172,6 +80241,7 @@ const usePopoverProps = props => {
80172
80241
  }
80173
80242
  }
80174
80243
  });
80244
+ const swipeToClose = swipeToCloseSides ? createSwipeToClose(swipeToCloseSides) : null;
80175
80245
  Object.assign(contentProps, {
80176
80246
  id,
80177
80247
  tabIndex,
@@ -80250,6 +80320,26 @@ const usePopoverProps = props => {
80250
80320
  onKeyDown?.(e);
80251
80321
  onKeyDownShortcuts(e);
80252
80322
  },
80323
+ ...(swipeToClose ? {
80324
+ "data-swipe-to-close": swipeToCloseSides.join(" "),
80325
+ // The axes it travels on when pushed out, said to the shared gesture
80326
+ // layer before any finger lands (see side_panel.jsx, which says the
80327
+ // same for the same reasons).
80328
+ "data-drag-travel": swipeAxesOf(swipeToCloseSides),
80329
+ "data-travel-by-drag": swipeAxesOf(swipeToCloseSides),
80330
+ "onTouchMove": e => {
80331
+ rest.onTouchMove?.(e);
80332
+ keepTouchRefusable(e);
80333
+ },
80334
+ "onPointerDown": e => {
80335
+ rest.onPointerDown?.(e);
80336
+ // Placed against an anchor at this opening: it is not in its
80337
+ // container to be pushed out of (warned at the opening).
80338
+ if (!anchorElementRef.current) {
80339
+ swipeToClose(e);
80340
+ }
80341
+ }
80342
+ } : null),
80253
80343
  // A popup opened from inside this one is in front of it, not a peer of
80254
80344
  // it: the group stops here.
80255
80345
  "children": popupGroup ? jsx(PopupGroupContext.Provider, {
@@ -80323,6 +80413,31 @@ const resolvePositionAreaAndAnimationKind = ({
80323
80413
  Popover.Close = PopupClose;
80324
80414
  Popover.Title = PopupTitle;
80325
80415
 
80416
+ // The sides a popover pushed away leaves by, or null when it is not pushed.
80417
+ const resolveSwipeToCloseSides = (closeByDrag, positionArea) => {
80418
+ if (!closeByDrag) {
80419
+ return null;
80420
+ }
80421
+ if (Array.isArray(closeByDrag)) {
80422
+ const sides = closeByDrag.filter(side => side in SWIPE_AXIS_BY_SIDE);
80423
+ return sides.length ? sides : null;
80424
+ }
80425
+ const side = dockedSideOf(positionArea);
80426
+ if (!side) {
80427
+ return null;
80428
+ }
80429
+ return [side];
80430
+ };
80431
+ const INSET_TOKEN_RE = /^inset\(\s*([a-z-]+)\s*\)$/;
80432
+ // The edge a popover docked to its container rests on: the word its
80433
+ // positionArea leads with, "bottom-start" resting on the bottom and "left-end"
80434
+ // on the left. "center" rests on none.
80435
+ const dockedSideOf = positionArea => {
80436
+ const token = INSET_TOKEN_RE.exec(positionArea)?.[1] ?? positionArea;
80437
+ const side = token.split("-")[0];
80438
+ return side in SWIPE_AXIS_BY_SIDE ? side : null;
80439
+ };
80440
+
80326
80441
  // The placement needs pixels: a value only CSS could evaluate (a percentage, a
80327
80442
  // calc()…) would put the popover at NaN, which is the top of the page.
80328
80443
  const resolveMarginInPixels = (margin, name) => {
@@ -108030,7 +108145,7 @@ const SidePanel = ({
108030
108145
  }) => {
108031
108146
  import.meta.css = [css, "@jsenv/navi/src/layout/side_panel.jsx"];
108032
108147
  const rest = dropPickerPopupProps(restGiven);
108033
- const onSwipePointerDown = closeByDrag ? createSwipeToClose(side) : null;
108148
+ const onSwipePointerDown = closeByDrag ? createSwipeToClose([side]) : null;
108034
108149
  return jsx(Popup, {
108035
108150
  mode: mode
108036
108151
  // Spread rather than written: the collision warning between `signal` and