@jsenv/navi 0.30.34 → 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.
@@ -25136,6 +25136,12 @@ const DOUBLE_CLICK_SLOP_ATTRIBUTE = "data-double-click-slop";
25136
25136
  // taps: said in the DOM at render time, for the CSS below and for the boxes above
25137
25137
  // to read.
25138
25138
  const SWIPE_AXES_ATTRIBUTE = "data-swipe";
25139
+ // A popup's `translate` is where it is placed, written inline by the placement
25140
+ // (applyNewPosition in @jsenv/dom), and an inline declaration wins over the
25141
+ // rule painting the pull: the swipe is answered with nothing following it.
25142
+ // Moving a popup by hand is pushing it away, which swipe_to_close.js does by
25143
+ // composing a transform under the placement.
25144
+ const POPUP_ELEMENT_SELECTOR = `[navi-control="popover"], [navi-control="dialog"]`;
25139
25145
  const LONGPRESS_ATTRIBUTE = "data-longpress";
25140
25146
  const DOUBLE_CLICK_ATTRIBUTE = "data-double-click";
25141
25147
 
@@ -25235,6 +25241,9 @@ defineInteractionDetector({
25235
25241
  });
25236
25242
  };
25237
25243
  if (axes) {
25244
+ if (element.matches(POPUP_ELEMENT_SELECTOR)) {
25245
+ warnSwipeOnPopupElement(element);
25246
+ }
25238
25247
  mark(SWIPE_AXES_ATTRIBUTE, axes);
25239
25248
  // Read by the boxes ABOVE this one: an axis swiped here is not theirs to
25240
25249
  // travel (see axesLeftBy).
@@ -25515,6 +25524,13 @@ const warnOnceAboutHoldTakenByInner = (element, innerElement) => {
25515
25524
  );
25516
25525
  };
25517
25526
 
25527
+ const warnSwipeOnPopupElement = (element) => {
25528
+ console.warn(
25529
+ `interactions: a swipe declared on a popup's own element does not move it — its translate is where the popup is placed. A popup pushed away closes with closeByDrag (Popover, SidePanel; a docked Dialog sheet does it on its own); a swipe on something it holds is declared on that element.`,
25530
+ element,
25531
+ );
25532
+ };
25533
+
25518
25534
  const startSwipe = (
25519
25535
  pointerDownEvent,
25520
25536
  {
@@ -42836,9 +42852,23 @@ const RouteTravel = ({
42836
42852
  params
42837
42853
  }) => new URL(route.buildUrl(params), window.location.href).pathname;
42838
42854
 
42855
+ // The pane laid out as the page on screen for the length of a movement, let go of
42856
+ // when the row rests. Not what readers are told the page held is
42857
+ // (route_travel_hold.js): that follows the hand, and goes at its lift.
42858
+ const heldPaneRef = useRef(null);
42859
+ const releasePaneAtRest = () => {
42860
+ const heldPane = heldPaneRef.current;
42861
+ if (heldPane) {
42862
+ heldPaneRef.current = null;
42863
+ heldPane.removeAttribute("data-route-row-held");
42864
+ }
42865
+ };
42866
+
42839
42867
  // One movement, whoever set it off, from the first thing that moves to the row
42840
- // at rest. Null when nothing moves.
42841
- const beginMovement = fields => {
42868
+ // at rest. Null when nothing moves. `paneAtRest` is the pane the row rests on as it
42869
+ // begins: laid out as the page on screen until the row rests again, wherever the
42870
+ // address goes meanwhile (see the CSS).
42871
+ const beginMovement = (fields, paneAtRest) => {
42842
42872
  const row = rowRef.current;
42843
42873
  if (movementRef.current) {
42844
42874
  Object.assign(movementRef.current, fields);
@@ -42853,6 +42883,11 @@ const RouteTravel = ({
42853
42883
  ...fields
42854
42884
  };
42855
42885
  movementRef.current = movement;
42886
+ // In a window the page is a picture, contained with what it holds.
42887
+ if (!windowed && paneAtRest) {
42888
+ heldPaneRef.current = paneAtRest;
42889
+ paneAtRest.setAttribute("data-route-row-held", "");
42890
+ }
42856
42891
  if (movement.scrollsDocument) {
42857
42892
  // Whatever the browser does with the offset meanwhile (clamping it to a page
42858
42893
  // shorter than the trip) is not the reader scrolling, and the address it
@@ -43090,24 +43125,6 @@ const RouteTravel = ({
43090
43125
  rowRef.current.removeAttribute("data-route-row-travelling");
43091
43126
  };
43092
43127
 
43093
- // The address runs ahead of a hand to build the page it pulls towards (onScroll):
43094
- // the pane under the hand stays laid out as the page on screen until the hand lets
43095
- // go, whatever `inert` says (see the CSS). What reads the page held is told apart
43096
- // (route_travel_hold.js), and both are let go of together.
43097
- const heldPaneRef = useRef(null);
43098
- const holdPaneOnScreen = pane => {
43099
- heldPaneRef.current = pane;
43100
- pane.setAttribute("data-route-row-held", "");
43101
- };
43102
- const releaseHold = () => {
43103
- releaseHeldPage();
43104
- const heldPane = heldPaneRef.current;
43105
- if (heldPane) {
43106
- heldPaneRef.current = null;
43107
- heldPane.removeAttribute("data-route-row-held");
43108
- }
43109
- };
43110
-
43111
43128
  // Every column back where the map says, and the row on the page of the address, in
43112
43129
  // one go: that page stands at the same place on screen either way.
43113
43130
  const putRowBack = movement => {
@@ -43136,7 +43153,7 @@ const RouteTravel = ({
43136
43153
  const position = row.scrollLeft;
43137
43154
  movement.rowTaken = true;
43138
43155
  // A travel no hand set off holds nothing: it goes where it goes.
43139
- releaseHold();
43156
+ releaseHeldPage();
43140
43157
  row.style.setProperty("--navi-route-row-stand", `${-position}px`);
43141
43158
  row.setAttribute("data-route-row-taken", "");
43142
43159
  afterFrames(2, () => {
@@ -43168,7 +43185,8 @@ const RouteTravel = ({
43168
43185
  putRowBack(movement);
43169
43186
  row.removeAttribute("data-route-row-taken");
43170
43187
  publishRouteRowMove(null);
43171
- releaseHold();
43188
+ releaseHeldPage();
43189
+ releasePaneAtRest();
43172
43190
  if (liveRef.current) {
43173
43191
  liveRef.current.style.top = "";
43174
43192
  }
@@ -43281,9 +43299,11 @@ const RouteTravel = ({
43281
43299
  return;
43282
43300
  }
43283
43301
  const movementBefore = movementRef.current;
43302
+ // The row still rests on the page answered before. Held in the commit that made
43303
+ // its pane inert, so no frame paints it contained.
43284
43304
  const movement = beginMovement({
43285
43305
  travelling: true
43286
- });
43306
+ }, panesOf(row)[pages.findIndex(each => samePage(each, answered))]);
43287
43307
  if (movementBefore?.byHand && !movementBefore.rowTaken) {
43288
43308
  sayRowMoves();
43289
43309
  takeRowFromHand(movement);
@@ -43351,7 +43371,7 @@ const RouteTravel = ({
43351
43371
  }
43352
43372
  const movement = moving || beginMovement({
43353
43373
  byHand: true
43354
- });
43374
+ }, pane);
43355
43375
  sayRowMoves();
43356
43376
  if (movement.travelling || !travelByDrag || !pane) {
43357
43377
  return;
@@ -43388,12 +43408,8 @@ const RouteTravel = ({
43388
43408
  return;
43389
43409
  }
43390
43410
  leavePictureOfLive(movement);
43391
- } else {
43392
- if (paneTowards.hasAttribute("data-built")) {
43393
- return;
43394
- }
43395
- // Before the address moves: the render that makes it inert must find it held.
43396
- holdPaneOnScreen(pane);
43411
+ } else if (paneTowards.hasAttribute("data-built")) {
43412
+ return;
43397
43413
  }
43398
43414
  holdPage(pages[currentIndex], page);
43399
43415
  askFor(page);
@@ -43424,8 +43440,9 @@ const RouteTravel = ({
43424
43440
  askForPageOfRow(index, movement);
43425
43441
  }
43426
43442
  // After the address: a pull coming back to its page must not light the tab the
43427
- // address ran ahead to, even for a render.
43428
- releaseHold();
43443
+ // address ran ahead to, even for a render. Only what readers are told: the
43444
+ // panes keep their layout until the row rests.
43445
+ releaseHeldPage();
43429
43446
  };
43430
43447
  const stopWatching = watchRowScroll(row, {
43431
43448
  onScroll,
@@ -43481,7 +43498,8 @@ const RouteTravel = ({
43481
43498
  row.removeAttribute("data-route-row-travelling");
43482
43499
  movementRef.current = null;
43483
43500
  publishRouteRowMove(null);
43484
- releaseHold();
43501
+ releaseHeldPage();
43502
+ releasePaneAtRest();
43485
43503
  };
43486
43504
  }, [windowed]);
43487
43505
 
@@ -77146,18 +77164,19 @@ const readRoom = () =>
77146
77164
  visualViewportHeightSignal.peek() - getVirtualKeyboardOverlayHeight();
77147
77165
 
77148
77166
  /**
77149
- * Pushing a popup docked to an edge (a side panel, a bottom sheet) back the way
77150
- * it came in closes it: it follows the finger while it travels, and letting go
77151
- * either finishes the departure or brings it back to rest — whichever the
77152
- * travel was already heading to.
77167
+ * Pushing a popup docked to an edge (a side panel, a bottom sheet, a toast)
77168
+ * back the way it came in closes it: it follows the finger while it travels,
77169
+ * and letting go either finishes the departure or brings it back to rest —
77170
+ * whichever the travel was already heading to. A popup may be given other ways
77171
+ * out than that one: a toast is also thrown sideways.
77153
77172
  *
77154
77173
  * Reading the pointer is not done here: when a press becomes a gesture, which
77155
77174
  * axis it leans on, how fast it was going when it was let go, who else has a
77156
77175
  * claim on it (a field, a scroller with room left, a box inside the panel that
77157
77176
  * travels the same way) is one gesture system for the whole codebase
77158
77177
  * (@jsenv/dom's startDragToTravel). A panel closing is a travel of exactly one
77159
- * box, towards the edge it is docked to and nowhere else — which is all this
77160
- * file has to say.
77178
+ * box, out by one of the edges it may leave by and nowhere else — which is all
77179
+ * this file has to say.
77161
77180
  *
77162
77181
  * The release travel is driven here (Web Animations) rather than handed to the
77163
77182
  * exit transition the `animation` prop may install: its pace is what is left to
@@ -77172,7 +77191,7 @@ const readRoom = () =>
77172
77191
  // less, so the panel keeps the same speed whenever the finger let go.
77173
77192
  const TRAVEL_DURATION = 220;
77174
77193
 
77175
- // Which way a popup docked to each side travels — read by the panel itself to
77194
+ // Which way a popup leaving by each side travels — read by the panel itself to
77176
77195
  // say so in the DOM, which is how a box travelling INSIDE it (a row of slides,
77177
77196
  // a route travel) knows that axis is already walked.
77178
77197
  const SWIPE_AXIS_BY_SIDE = {
@@ -77181,12 +77200,27 @@ const SWIPE_AXIS_BY_SIDE = {
77181
77200
  top: "y",
77182
77201
  bottom: "y",
77183
77202
  };
77184
- // Which way the finger pushes to send the panel back where it came from, in
77185
- // screen coordinates: positive is right/down, the same sign the gesture reports.
77203
+ // Which way the finger pushes to send the panel out by each side, in screen
77204
+ // coordinates: positive is right/down, the same sign the gesture reports.
77186
77205
  const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
77187
77206
 
77188
77207
  /**
77189
- * Builds the `pointerdown` handler a popup docked to `side` answers with.
77208
+ * The axes a popup leaving by `sides` travels on, as the gesture and the DOM
77209
+ * say them: "x", "y" or "xy".
77210
+ */
77211
+ const swipeAxesOf = (sides) => {
77212
+ const hasX = sides.some((side) => SWIPE_AXIS_BY_SIDE[side] === "x");
77213
+ const hasY = sides.some((side) => SWIPE_AXIS_BY_SIDE[side] === "y");
77214
+ if (hasX && hasY) {
77215
+ return "xy";
77216
+ }
77217
+ return hasX ? "x" : "y";
77218
+ };
77219
+
77220
+ /**
77221
+ * Builds the `pointerdown` handler of a popup that leaves by any of `sides`:
77222
+ * usually the one edge it is docked to, the way it came in; a toast is also
77223
+ * thrown sideways, and leaves the way it was thrown.
77190
77224
  *
77191
77225
  * `grip` says where the gesture may start, as a selector the pressed element is
77192
77226
  * matched against with `closest`. A popup that names one is held THERE and
@@ -77194,11 +77228,17 @@ const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
77194
77228
  * and a press on it is that content's — including gestures of its own, which
77195
77229
  * this file has no way of knowing about. A popup that names no grip is pushed
77196
77230
  * from its whole surface, which only suits one made of nothing else (a side
77197
- * panel showing a page).
77198
- */
77199
- const createSwipeToClose = (side, { grip } = {}) => {
77200
- const axis = SWIPE_AXIS_BY_SIDE[side];
77201
- const closeDirection = CLOSE_DIRECTION_BY_SIDE[side];
77231
+ * panel showing a page, a toast).
77232
+ */
77233
+ const createSwipeToClose = (sides, { grip } = {}) => {
77234
+ const axes = swipeAxesOf(sides);
77235
+ // The side a push along `axis` towards `sign` leaves by, if it leaves at all.
77236
+ const sideTowards = (axis, sign) =>
77237
+ sides.find(
77238
+ (side) =>
77239
+ SWIPE_AXIS_BY_SIDE[side] === axis &&
77240
+ CLOSE_DIRECTION_BY_SIDE[side] === sign,
77241
+ );
77202
77242
 
77203
77243
  return (pointerDownEvent) => {
77204
77244
  const panelEl = pointerDownEvent.currentTarget;
@@ -77215,6 +77255,11 @@ const createSwipeToClose = (side, { grip } = {}) => {
77215
77255
  }
77216
77256
  }
77217
77257
 
77258
+ // Decided when the finger picks its axis (onStart), once for the gesture.
77259
+ let axis;
77260
+ // How far the panel travels to be out, for each way it can go on that axis.
77261
+ const distanceOutBySign = new Map();
77262
+
77218
77263
  // How far the panel has been pulled, as the style showing it — written on
77219
77264
  // the panel while the finger drives it, handed to the release travel as
77220
77265
  // its two keyframes. The gesture reports a distance in screen coordinates,
@@ -77224,7 +77269,7 @@ const createSwipeToClose = (side, { grip } = {}) => {
77224
77269
  // composes under the placement instead of replacing it.
77225
77270
  const styleAt = (distance) => ({
77226
77271
  transform: translateOf(axis, distance),
77227
- clipPath: clipOf(side, distance),
77272
+ clipPath: clipOf(sides, axis, distance),
77228
77273
  });
77229
77274
  const paint = (distance) => {
77230
77275
  Object.assign(panelEl.style, styleAt(distance));
@@ -77236,21 +77281,22 @@ const createSwipeToClose = (side, { grip } = {}) => {
77236
77281
  };
77237
77282
  // The resting value is written first and the animation only covers the way
77238
77283
  // to it: both end on the same number, so nothing is seen changing when the
77239
- // animation hands the panel back to its own style.
77240
- const travelTo = (from, to, onArrival) => {
77284
+ // animation hands the panel back to its own style. `span` is the full travel
77285
+ // on that side, which the duration is a fraction of.
77286
+ const travelTo = (from, to, span, onArrival) => {
77241
77287
  const covered = from > to ? from - to : to - from;
77242
77288
  paint(to);
77243
77289
  const animation = panelEl.animate([styleAt(from), styleAt(to)], {
77244
- duration: (covered / sizeOf(panelEl, axis)) * TRAVEL_DURATION,
77290
+ duration: (covered / span) * TRAVEL_DURATION,
77245
77291
  easing: "ease-out",
77246
77292
  });
77247
77293
  animation.finished.then(onArrival, () => {});
77248
77294
  };
77249
- const close = (event) => {
77295
+ const close = (event, out, span) => {
77250
77296
  triggerNaviCommand(panelEl, "--navi-close", event);
77251
77297
  if (panelEl.getAttribute("aria-expanded") === "true") {
77252
77298
  // Refused: the panel is staying, so it goes back where it was.
77253
- travelTo(sizeOf(panelEl, axis) * closeDirection, 0, restore);
77299
+ travelTo(out, 0, span, restore);
77254
77300
  return;
77255
77301
  }
77256
77302
  restore();
@@ -77258,79 +77304,94 @@ const createSwipeToClose = (side, { grip } = {}) => {
77258
77304
 
77259
77305
  startDragToTravel(pointerDownEvent, {
77260
77306
  element: panelEl,
77261
- axes: axis,
77262
- onStart: ({ sign, target }) => {
77263
- if (sign !== closeDirection) {
77307
+ axes,
77308
+ onStart: ({ axis: gestureAxis, sign, target }) => {
77309
+ const side = sideTowards(gestureAxis, sign);
77310
+ if (!side) {
77264
77311
  // Pushing the panel further in is not a travel: there is nowhere that
77265
77312
  // way, and reading it as one would make the panel lean at every
77266
77313
  // gesture that starts by going the wrong way.
77267
77314
  return false;
77268
77315
  }
77269
- const size = sizeOf(panelEl, axis);
77316
+ const size = distanceOut(panelEl, side);
77270
77317
  if (!size) {
77271
77318
  return false;
77272
77319
  }
77273
77320
  // Something else with a better claim on the gesture: a scroller between
77274
77321
  // the finger and the panel, with room left that way.
77275
- if (scrollRoomTowards(target, panelEl, axis, sign)) {
77322
+ if (scrollRoomTowards(target, panelEl, gestureAxis, sign)) {
77276
77323
  return false;
77277
77324
  }
77325
+ axis = gestureAxis;
77326
+ distanceOutBySign.set(sign, size);
77327
+ const otherSide = sideTowards(gestureAxis, -sign);
77328
+ if (otherSide) {
77329
+ distanceOutBySign.set(-sign, distanceOut(panelEl, otherSide));
77330
+ }
77278
77331
  panelEl.style.transitionProperty = "none";
77279
- // The way out is the only way there is anything: pulling the other way
77280
- // leans against a wall and comes back.
77332
+ // Only the ways out lead anywhere: pulling towards a side the panel
77333
+ // does not leave by leans against a wall and comes back.
77281
77334
  return {
77282
77335
  size,
77283
- travelBack: closeDirection > 0,
77284
- travelOn: closeDirection < 0,
77336
+ travelBack: Boolean(sideTowards(gestureAxis, 1)),
77337
+ travelOn: Boolean(sideTowards(gestureAxis, -1)),
77285
77338
  };
77286
77339
  },
77287
77340
  onPull: ({ pulled }) => {
77288
77341
  paint(pulled);
77289
77342
  },
77290
- onEnd: ({ pulled, size, travels, event }) => {
77343
+ onEnd: ({ pulled, size, sign, travels, event }) => {
77291
77344
  if (travels) {
77292
- travelTo(pulled, size * closeDirection, () => close(event));
77345
+ const span = distanceOutBySign.get(sign);
77346
+ travelTo(pulled, span * sign, span, () =>
77347
+ close(event, span * sign, span),
77348
+ );
77293
77349
  return;
77294
77350
  }
77295
- travelTo(pulled, 0, restore);
77351
+ travelTo(pulled, 0, size, restore);
77296
77352
  },
77297
77353
  });
77298
77354
  };
77299
77355
  };
77300
77356
 
77301
- const sizeOf = (element, axis) => {
77357
+ // What takes the popup out of sight: its own size, plus the gap a margin keeps
77358
+ // between it and the edge it is pushed through — the room applyNewPosition
77359
+ // publishes on that side. Travelling its size alone, a popup kept off its edge
77360
+ // (a toast 12px above a bar) would stop with that much of itself still showing.
77361
+ const distanceOut = (element, side) => {
77302
77362
  const rect = element.getBoundingClientRect();
77303
- return axis === "x" ? rect.width : rect.height;
77363
+ const size = SWIPE_AXIS_BY_SIDE[side] === "x" ? rect.width : rect.height;
77364
+ const room = parseFloat(
77365
+ element.style.getPropertyValue(`--container-position-room-${side}`),
77366
+ );
77367
+ return Number.isNaN(room) ? size : size + room;
77304
77368
  };
77305
77369
  const translateOf = (axis, distance) =>
77306
77370
  axis === "x"
77307
77371
  ? `translate(${distance}px, 0px)`
77308
77372
  : `translate(0px, ${distance}px)`;
77309
77373
  // The same cut the entry/exit animation makes (popup_css.js), at a distance the
77310
- // finger decides instead of a transition: the edge the popup is pushed back
77311
- // through, as popup_css.js placed it (--x-popup-cut-*-at-area — on the area's
77312
- // edge where a band of glass lies past it, 100vmax out where the window's own
77313
- // edge does the cutting), plus what has been pulled, so the cut holds that line
77314
- // while the box travels under it. The three other sides are left far outside
77315
- // the box — the pull never takes the popup past them, and cutting there would
77316
- // only shave what it legitimately paints outside its own box. Reading the
77317
- // policy from popup_css.js rather than the rooms keeps the release travel
77318
- // (a Web Animation on transform AND clip-path, two clocks) from animating a
77319
- // cut the entrance would not.
77374
+ // finger decides instead of a transition: each edge the popup may leave by on
77375
+ // the axis it travels, as popup_css.js placed it (--x-popup-cut-*-at-area — on
77376
+ // the area's edge where a band of glass lies past it, 100vmax out where the
77377
+ // window's own edge does the cutting), moved by what has been pulled towards
77378
+ // it, so the cut holds that line while the box travels under it, whichever way.
77379
+ // Every other side is left far outside the box — the pull never takes the popup
77380
+ // past them, and cutting there would only shave what it legitimately paints
77381
+ // outside its own box. Reading the policy from popup_css.js rather than the
77382
+ // rooms keeps the release travel (a Web Animation on transform AND clip-path,
77383
+ // two clocks) from animating a cut the entrance would not.
77320
77384
  const UNCUT = "-100vmax";
77321
- const clipOf = (side, distance) => {
77322
- const pulled = distance < 0 ? -distance : distance;
77323
- const cut = `calc(var(--x-popup-cut-${side}-at-area, ${UNCUT}) + ${pulled}px)`;
77324
- if (side === "top") {
77325
- return `inset(${cut} ${UNCUT} ${UNCUT} ${UNCUT})`;
77326
- }
77327
- if (side === "right") {
77328
- return `inset(${UNCUT} ${cut} ${UNCUT} ${UNCUT})`;
77329
- }
77330
- if (side === "bottom") {
77331
- return `inset(${UNCUT} ${UNCUT} ${cut} ${UNCUT})`;
77332
- }
77333
- return `inset(${UNCUT} ${UNCUT} ${UNCUT} ${cut})`;
77385
+ const INSET_ORDER = ["top", "right", "bottom", "left"];
77386
+ const clipOf = (sides, axis, distance) => {
77387
+ const insets = INSET_ORDER.map((side) => {
77388
+ if (!sides.includes(side) || SWIPE_AXIS_BY_SIDE[side] !== axis) {
77389
+ return UNCUT;
77390
+ }
77391
+ const towards = distance * CLOSE_DIRECTION_BY_SIDE[side];
77392
+ return `calc(var(--x-popup-cut-${side}-at-area, ${UNCUT}) + ${towards}px)`;
77393
+ });
77394
+ return `inset(${insets.join(" ")})`;
77334
77395
  };
77335
77396
 
77336
77397
  /**
@@ -79120,7 +79181,7 @@ const useDialogProps = props => {
79120
79181
  // actually resting on an edge: anywhere else the gesture would send the
79121
79182
  // dialog somewhere it never came from.
79122
79183
  const swipeToCloseSide = isDocked && (parsedPositionArea.y === "top" || parsedPositionArea.y === "bottom") ? parsedPositionArea.y : null;
79123
- const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose(swipeToCloseSide, {
79184
+ const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose([swipeToCloseSide], {
79124
79185
  grip: DOCKED_SWIPE_GRIP
79125
79186
  }) : null;
79126
79187
  // A corner sitting exactly on the container's own corner must not be
@@ -80300,6 +80361,16 @@ const css$C = /* css */`
80300
80361
  to filter). An entrance animation moves the popover through scale and
80301
80362
  transform instead, which compose under it: see popup_css.js. */
80302
80363
 
80364
+ /* What a touch may do on a popover pushed sideways to close it (see
80365
+ swipe_to_close.js): the push takes the sideways axis, the page keeps the
80366
+ vertical scroll. A push up or down travels the way the page scrolls and
80367
+ cannot give that axis away: the browser keeps it, and the gesture is
80368
+ settled by keepTouchRefusable. */
80369
+ &[data-swipe-to-close~="left"],
80370
+ &[data-swipe-to-close~="right"] {
80371
+ touch-action: pan-y;
80372
+ }
80373
+
80303
80374
  /* The clamped max, not --popover-maxmax-*: that one ignores what the
80304
80375
  placement left available, which reaches here through
80305
80376
  --container-position-remaining-* (see applyNewPosition) — the min()
@@ -80530,6 +80601,20 @@ const css$C = /* css */`
80530
80601
  * overlap with the anchor; wrap it in `inset(...)` (e.g. `inset(top)`,
80531
80602
  * `inset(top-left)`) to overlap the anchor instead (edges touching or
80532
80603
  * inside it).
80604
+ * @param {boolean|Array<"top"|"right"|"bottom"|"left">} [props.closeByDrag=false] -
80605
+ * Pushing the popover out of its container closes it: it follows the pointer
80606
+ * and, when released, finishes leaving or comes back to rest. `true` pushes
80607
+ * it back through the edge it is docked to, the word `positionArea` leads
80608
+ * with (`"bottom"`, `"bottom-start"`: pushed down; `"left-end"`: pushed
80609
+ * left); a centred popover rests on no edge and is not pushed (warned in
80610
+ * dev). A list names the sides it may leave by, and it leaves the way it was
80611
+ * pushed: `["bottom", "left", "right"]` for a toast pushed back down behind
80612
+ * the bar or thrown sideways. A popover placed against an anchor at its
80613
+ * opening is not pushed either way (warned in dev). It is pushed from its
80614
+ * whole surface, so it suits a popover holding little else: a button inside
80615
+ * keeps its tap, and a press that turns into a push swallows the click it
80616
+ * would have left. The close keeps, as a cross's does. See docs/interactions.md
80617
+ * for a swipe on the popover's own element, which does not move it.
80533
80618
  * @param {string} [props.positionAreaFixed] - Overrides `positionArea` once
80534
80619
  * the popover has actually been positioned once, so a live reposition
80535
80620
  * (e.g. anchor moved) doesn't jump to a different side.
@@ -80983,6 +81068,7 @@ const usePopoverProps = props => {
80983
81068
  positionAreaWhenAnchorIsInvalid,
80984
81069
  marginWithContainer = 0,
80985
81070
  container = "app",
81071
+ closeByDrag = false,
80986
81072
  // "close" → pointer press outside closes (a plain close = commit)
80987
81073
  // "cancel" → pointer press outside closes AS A CANCEL (revert)
80988
81074
  // "capture"→ absorb the press, stay open
@@ -81116,6 +81202,7 @@ const usePopoverProps = props => {
81116
81202
  return popupGroup.register(openController);
81117
81203
  }, [popupGroup, openController]);
81118
81204
  const hasBackdrop = pressOutside !== "ignore" && backdrop && !popupGroup;
81205
+ const swipeToCloseSides = resolveSwipeToCloseSides(closeByDrag, positionArea);
81119
81206
  if (!popupGroup && !backdrop && pressOutside === "capture") {
81120
81207
  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".`);
81121
81208
  }
@@ -81265,6 +81352,9 @@ const usePopoverProps = props => {
81265
81352
  }
81266
81353
  const hasAnchorElement = Boolean(anchorElement);
81267
81354
  anchorElementRef.current = anchorElement || null;
81355
+ if (swipeToCloseSides && hasAnchorElement) {
81356
+ console.warn(`Popover: closeByDrag is ignored, this opening placed the popover against an anchor rather than in its container. A popover pushed away has no anchor: anchorCustomEventDetail="ignore" and no anchor prop.`, anchorElement);
81357
+ }
81268
81358
  // getPositionedParent already resolves to document.documentElement for
81269
81359
  // the via-attribute renderer on its own (popoverEl's own `popover`
81270
81360
  // attribute, present from render regardless of whether it's actually
@@ -81959,6 +82049,7 @@ const usePopoverProps = props => {
81959
82049
  }
81960
82050
  }
81961
82051
  });
82052
+ const swipeToClose = swipeToCloseSides ? createSwipeToClose(swipeToCloseSides) : null;
81962
82053
  Object.assign(contentProps, {
81963
82054
  id,
81964
82055
  tabIndex,
@@ -82037,6 +82128,26 @@ const usePopoverProps = props => {
82037
82128
  onKeyDown?.(e);
82038
82129
  onKeyDownShortcuts(e);
82039
82130
  },
82131
+ ...(swipeToClose ? {
82132
+ "data-swipe-to-close": swipeToCloseSides.join(" "),
82133
+ // The axes it travels on when pushed out, said to the shared gesture
82134
+ // layer before any finger lands (see side_panel.jsx, which says the
82135
+ // same for the same reasons).
82136
+ "data-drag-travel": swipeAxesOf(swipeToCloseSides),
82137
+ "data-travel-by-drag": swipeAxesOf(swipeToCloseSides),
82138
+ "onTouchMove": e => {
82139
+ rest.onTouchMove?.(e);
82140
+ keepTouchRefusable(e);
82141
+ },
82142
+ "onPointerDown": e => {
82143
+ rest.onPointerDown?.(e);
82144
+ // Placed against an anchor at this opening: it is not in its
82145
+ // container to be pushed out of (warned at the opening).
82146
+ if (!anchorElementRef.current) {
82147
+ swipeToClose(e);
82148
+ }
82149
+ }
82150
+ } : null),
82040
82151
  // A popup opened from inside this one is in front of it, not a peer of
82041
82152
  // it: the group stops here.
82042
82153
  "children": popupGroup ? jsx(PopupGroupContext.Provider, {
@@ -82110,6 +82221,37 @@ const resolvePositionAreaAndAnimationKind = ({
82110
82221
  Popover.Close = PopupClose;
82111
82222
  Popover.Title = PopupTitle;
82112
82223
 
82224
+ // The sides a popover pushed away leaves by, or null when it is not pushed.
82225
+ const resolveSwipeToCloseSides = (closeByDrag, positionArea) => {
82226
+ if (!closeByDrag) {
82227
+ return null;
82228
+ }
82229
+ if (Array.isArray(closeByDrag)) {
82230
+ const sides = closeByDrag.filter(side => side in SWIPE_AXIS_BY_SIDE);
82231
+ if (sides.length !== closeByDrag.length) {
82232
+ console.warn(`Popover: closeByDrag={${JSON.stringify(closeByDrag)}} names something that is not a side; the sides are "top", "right", "bottom" and "left".`);
82233
+ }
82234
+ return sides.length ? sides : null;
82235
+ }
82236
+ const side = dockedSideOf(positionArea);
82237
+ if (!side) {
82238
+ {
82239
+ console.warn(`Popover: closeByDrag with positionArea="${positionArea}" rests on no edge, so there is no way back to push it through. Dock it to one ("bottom", "top", "left", "right", or a token leading with one of them), or name the sides it leaves by: closeByDrag={["left", "right"]}.`);
82240
+ }
82241
+ return null;
82242
+ }
82243
+ return [side];
82244
+ };
82245
+ const INSET_TOKEN_RE = /^inset\(\s*([a-z-]+)\s*\)$/;
82246
+ // The edge a popover docked to its container rests on: the word its
82247
+ // positionArea leads with, "bottom-start" resting on the bottom and "left-end"
82248
+ // on the left. "center" rests on none.
82249
+ const dockedSideOf = positionArea => {
82250
+ const token = INSET_TOKEN_RE.exec(positionArea)?.[1] ?? positionArea;
82251
+ const side = token.split("-")[0];
82252
+ return side in SWIPE_AXIS_BY_SIDE ? side : null;
82253
+ };
82254
+
82113
82255
  // The placement needs pixels: a value only CSS could evaluate (a percentage, a
82114
82256
  // calc()…) would put the popover at NaN, which is the top of the page.
82115
82257
  const resolveMarginInPixels = (margin, name) => {
@@ -110171,7 +110313,7 @@ const SidePanel = ({
110171
110313
  }) => {
110172
110314
  import.meta.css = [css, "@jsenv/navi/src/layout/side_panel.jsx"];
110173
110315
  const rest = dropPickerPopupProps(restGiven, "SidePanel");
110174
- const onSwipePointerDown = closeByDrag ? createSwipeToClose(side) : null;
110316
+ const onSwipePointerDown = closeByDrag ? createSwipeToClose([side]) : null;
110175
110317
  return jsx(Popup, {
110176
110318
  mode: mode
110177
110319
  // Spread rather than written: the collision warning between `signal` and