@jsenv/navi 0.30.35 → 0.30.37

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
  {
@@ -30502,8 +30518,9 @@ const SCOPED_MANY_OPTIONS = new Set([
30502
30518
  * policy as that verb, and is a write of its own for a dependent:
30503
30519
  * `{ resource: ORDER, on: ["cancel"] }`. Its `rerunOn` says which reads of
30504
30520
  * this resource it reruns (`{ GET_RANGE: true }`); a read it leaves out
30505
- * follows `rerunOn` for its verb. A name a verb takes, or one the resource
30506
- * already exposes (`withParams`, `store`…), throws. A `.scopedOne()` or
30521
+ * follows `rerunOn` for its verb. A verb's name in any case (`post`,
30522
+ * `get_many`…), or one the resource already exposes (`withParams`, `store`…),
30523
+ * throws. A `.scopedOne()` or
30507
30524
  * `.scopedMany()` takes `writes` too, `.scopedMany()` with the plural verbs.
30508
30525
  * @param {{ GET?: { ms?: number, max?: number }, GET_MANY?: { ms?: number, max?: number } }} [restCallbacks.keep] -
30509
30526
  * how long an answer of a read is trusted, coming back to the same params
@@ -32068,12 +32085,15 @@ ${name}.many("${propertyName}") source location: ${declarationSite}`,
32068
32085
 
32069
32086
  // expose one action per write named in `writes`
32070
32087
  if (writes) {
32088
+ const membersExposed = Object.keys(stateFacade).filter(
32089
+ (key) => !VERB_KEYS.includes(key),
32090
+ );
32071
32091
  for (const [writeName, write] of Object.entries(writes)) {
32072
32092
  assertWriteCanBeNamed(
32073
32093
  name,
32074
32094
  writeName,
32075
32095
  write,
32076
- stateFacade,
32096
+ membersExposed,
32077
32097
  namedWriteVerbs,
32078
32098
  );
32079
32099
  const { verb: writeVerb, callback, rerunOn: writeRerunOn } = write;
@@ -32386,7 +32406,7 @@ const assertWriteCanBeNamed = (
32386
32406
  name,
32387
32407
  writeName,
32388
32408
  write,
32389
- stateFacade,
32409
+ membersExposed,
32390
32410
  namedWriteVerbs,
32391
32411
  ) => {
32392
32412
  const where = `resource(${name}) writes.${writeName}`;
@@ -32395,9 +32415,9 @@ const assertWriteCanBeNamed = (
32395
32415
  `${where}: "${writeName}" is a verb's name — a write named in "writes" takes a name of its own (cancel, archive…)`,
32396
32416
  );
32397
32417
  }
32398
- if (Object.hasOwn(stateFacade, writeName)) {
32418
+ if (membersExposed.includes(writeName)) {
32399
32419
  throw new Error(
32400
- `${where}: the resource already exposes "${writeName}" — pick another name`,
32420
+ `${where}: the resource already exposes "${writeName}" — a write takes a name none of its members has: ${membersExposed.join(", ")}`,
32401
32421
  );
32402
32422
  }
32403
32423
  if (!write || typeof write !== "object") {
@@ -77148,18 +77168,19 @@ const readRoom = () =>
77148
77168
  visualViewportHeightSignal.peek() - getVirtualKeyboardOverlayHeight();
77149
77169
 
77150
77170
  /**
77151
- * Pushing a popup docked to an edge (a side panel, a bottom sheet) back the way
77152
- * it came in closes it: it follows the finger while it travels, and letting go
77153
- * either finishes the departure or brings it back to rest — whichever the
77154
- * travel was already heading to.
77171
+ * Pushing a popup docked to an edge (a side panel, a bottom sheet, a toast)
77172
+ * back the way it came in closes it: it follows the finger while it travels,
77173
+ * and letting go either finishes the departure or brings it back to rest —
77174
+ * whichever the travel was already heading to. A popup may be given other ways
77175
+ * out than that one: a toast is also thrown sideways.
77155
77176
  *
77156
77177
  * Reading the pointer is not done here: when a press becomes a gesture, which
77157
77178
  * axis it leans on, how fast it was going when it was let go, who else has a
77158
77179
  * claim on it (a field, a scroller with room left, a box inside the panel that
77159
77180
  * travels the same way) is one gesture system for the whole codebase
77160
77181
  * (@jsenv/dom's startDragToTravel). A panel closing is a travel of exactly one
77161
- * box, towards the edge it is docked to and nowhere else — which is all this
77162
- * file has to say.
77182
+ * box, out by one of the edges it may leave by and nowhere else — which is all
77183
+ * this file has to say.
77163
77184
  *
77164
77185
  * The release travel is driven here (Web Animations) rather than handed to the
77165
77186
  * exit transition the `animation` prop may install: its pace is what is left to
@@ -77174,7 +77195,7 @@ const readRoom = () =>
77174
77195
  // less, so the panel keeps the same speed whenever the finger let go.
77175
77196
  const TRAVEL_DURATION = 220;
77176
77197
 
77177
- // Which way a popup docked to each side travels — read by the panel itself to
77198
+ // Which way a popup leaving by each side travels — read by the panel itself to
77178
77199
  // say so in the DOM, which is how a box travelling INSIDE it (a row of slides,
77179
77200
  // a route travel) knows that axis is already walked.
77180
77201
  const SWIPE_AXIS_BY_SIDE = {
@@ -77183,12 +77204,27 @@ const SWIPE_AXIS_BY_SIDE = {
77183
77204
  top: "y",
77184
77205
  bottom: "y",
77185
77206
  };
77186
- // Which way the finger pushes to send the panel back where it came from, in
77187
- // screen coordinates: positive is right/down, the same sign the gesture reports.
77207
+ // Which way the finger pushes to send the panel out by each side, in screen
77208
+ // coordinates: positive is right/down, the same sign the gesture reports.
77188
77209
  const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
77189
77210
 
77190
77211
  /**
77191
- * Builds the `pointerdown` handler a popup docked to `side` answers with.
77212
+ * The axes a popup leaving by `sides` travels on, as the gesture and the DOM
77213
+ * say them: "x", "y" or "xy".
77214
+ */
77215
+ const swipeAxesOf = (sides) => {
77216
+ const hasX = sides.some((side) => SWIPE_AXIS_BY_SIDE[side] === "x");
77217
+ const hasY = sides.some((side) => SWIPE_AXIS_BY_SIDE[side] === "y");
77218
+ if (hasX && hasY) {
77219
+ return "xy";
77220
+ }
77221
+ return hasX ? "x" : "y";
77222
+ };
77223
+
77224
+ /**
77225
+ * Builds the `pointerdown` handler of a popup that leaves by any of `sides`:
77226
+ * usually the one edge it is docked to, the way it came in; a toast is also
77227
+ * thrown sideways, and leaves the way it was thrown.
77192
77228
  *
77193
77229
  * `grip` says where the gesture may start, as a selector the pressed element is
77194
77230
  * matched against with `closest`. A popup that names one is held THERE and
@@ -77196,11 +77232,17 @@ const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
77196
77232
  * and a press on it is that content's — including gestures of its own, which
77197
77233
  * this file has no way of knowing about. A popup that names no grip is pushed
77198
77234
  * from its whole surface, which only suits one made of nothing else (a side
77199
- * panel showing a page).
77200
- */
77201
- const createSwipeToClose = (side, { grip } = {}) => {
77202
- const axis = SWIPE_AXIS_BY_SIDE[side];
77203
- const closeDirection = CLOSE_DIRECTION_BY_SIDE[side];
77235
+ * panel showing a page, a toast).
77236
+ */
77237
+ const createSwipeToClose = (sides, { grip } = {}) => {
77238
+ const axes = swipeAxesOf(sides);
77239
+ // The side a push along `axis` towards `sign` leaves by, if it leaves at all.
77240
+ const sideTowards = (axis, sign) =>
77241
+ sides.find(
77242
+ (side) =>
77243
+ SWIPE_AXIS_BY_SIDE[side] === axis &&
77244
+ CLOSE_DIRECTION_BY_SIDE[side] === sign,
77245
+ );
77204
77246
 
77205
77247
  return (pointerDownEvent) => {
77206
77248
  const panelEl = pointerDownEvent.currentTarget;
@@ -77217,6 +77259,11 @@ const createSwipeToClose = (side, { grip } = {}) => {
77217
77259
  }
77218
77260
  }
77219
77261
 
77262
+ // Decided when the finger picks its axis (onStart), once for the gesture.
77263
+ let axis;
77264
+ // How far the panel travels to be out, for each way it can go on that axis.
77265
+ const distanceOutBySign = new Map();
77266
+
77220
77267
  // How far the panel has been pulled, as the style showing it — written on
77221
77268
  // the panel while the finger drives it, handed to the release travel as
77222
77269
  // its two keyframes. The gesture reports a distance in screen coordinates,
@@ -77226,7 +77273,7 @@ const createSwipeToClose = (side, { grip } = {}) => {
77226
77273
  // composes under the placement instead of replacing it.
77227
77274
  const styleAt = (distance) => ({
77228
77275
  transform: translateOf(axis, distance),
77229
- clipPath: clipOf(side, distance),
77276
+ clipPath: clipOf(sides, axis, distance),
77230
77277
  });
77231
77278
  const paint = (distance) => {
77232
77279
  Object.assign(panelEl.style, styleAt(distance));
@@ -77238,21 +77285,22 @@ const createSwipeToClose = (side, { grip } = {}) => {
77238
77285
  };
77239
77286
  // The resting value is written first and the animation only covers the way
77240
77287
  // to it: both end on the same number, so nothing is seen changing when the
77241
- // animation hands the panel back to its own style.
77242
- const travelTo = (from, to, onArrival) => {
77288
+ // animation hands the panel back to its own style. `span` is the full travel
77289
+ // on that side, which the duration is a fraction of.
77290
+ const travelTo = (from, to, span, onArrival) => {
77243
77291
  const covered = from > to ? from - to : to - from;
77244
77292
  paint(to);
77245
77293
  const animation = panelEl.animate([styleAt(from), styleAt(to)], {
77246
- duration: (covered / sizeOf(panelEl, axis)) * TRAVEL_DURATION,
77294
+ duration: (covered / span) * TRAVEL_DURATION,
77247
77295
  easing: "ease-out",
77248
77296
  });
77249
77297
  animation.finished.then(onArrival, () => {});
77250
77298
  };
77251
- const close = (event) => {
77299
+ const close = (event, out, span) => {
77252
77300
  triggerNaviCommand(panelEl, "--navi-close", event);
77253
77301
  if (panelEl.getAttribute("aria-expanded") === "true") {
77254
77302
  // Refused: the panel is staying, so it goes back where it was.
77255
- travelTo(sizeOf(panelEl, axis) * closeDirection, 0, restore);
77303
+ travelTo(out, 0, span, restore);
77256
77304
  return;
77257
77305
  }
77258
77306
  restore();
@@ -77260,79 +77308,94 @@ const createSwipeToClose = (side, { grip } = {}) => {
77260
77308
 
77261
77309
  startDragToTravel(pointerDownEvent, {
77262
77310
  element: panelEl,
77263
- axes: axis,
77264
- onStart: ({ sign, target }) => {
77265
- if (sign !== closeDirection) {
77311
+ axes,
77312
+ onStart: ({ axis: gestureAxis, sign, target }) => {
77313
+ const side = sideTowards(gestureAxis, sign);
77314
+ if (!side) {
77266
77315
  // Pushing the panel further in is not a travel: there is nowhere that
77267
77316
  // way, and reading it as one would make the panel lean at every
77268
77317
  // gesture that starts by going the wrong way.
77269
77318
  return false;
77270
77319
  }
77271
- const size = sizeOf(panelEl, axis);
77320
+ const size = distanceOut(panelEl, side);
77272
77321
  if (!size) {
77273
77322
  return false;
77274
77323
  }
77275
77324
  // Something else with a better claim on the gesture: a scroller between
77276
77325
  // the finger and the panel, with room left that way.
77277
- if (scrollRoomTowards(target, panelEl, axis, sign)) {
77326
+ if (scrollRoomTowards(target, panelEl, gestureAxis, sign)) {
77278
77327
  return false;
77279
77328
  }
77329
+ axis = gestureAxis;
77330
+ distanceOutBySign.set(sign, size);
77331
+ const otherSide = sideTowards(gestureAxis, -sign);
77332
+ if (otherSide) {
77333
+ distanceOutBySign.set(-sign, distanceOut(panelEl, otherSide));
77334
+ }
77280
77335
  panelEl.style.transitionProperty = "none";
77281
- // The way out is the only way there is anything: pulling the other way
77282
- // leans against a wall and comes back.
77336
+ // Only the ways out lead anywhere: pulling towards a side the panel
77337
+ // does not leave by leans against a wall and comes back.
77283
77338
  return {
77284
77339
  size,
77285
- travelBack: closeDirection > 0,
77286
- travelOn: closeDirection < 0,
77340
+ travelBack: Boolean(sideTowards(gestureAxis, 1)),
77341
+ travelOn: Boolean(sideTowards(gestureAxis, -1)),
77287
77342
  };
77288
77343
  },
77289
77344
  onPull: ({ pulled }) => {
77290
77345
  paint(pulled);
77291
77346
  },
77292
- onEnd: ({ pulled, size, travels, event }) => {
77347
+ onEnd: ({ pulled, size, sign, travels, event }) => {
77293
77348
  if (travels) {
77294
- travelTo(pulled, size * closeDirection, () => close(event));
77349
+ const span = distanceOutBySign.get(sign);
77350
+ travelTo(pulled, span * sign, span, () =>
77351
+ close(event, span * sign, span),
77352
+ );
77295
77353
  return;
77296
77354
  }
77297
- travelTo(pulled, 0, restore);
77355
+ travelTo(pulled, 0, size, restore);
77298
77356
  },
77299
77357
  });
77300
77358
  };
77301
77359
  };
77302
77360
 
77303
- const sizeOf = (element, axis) => {
77361
+ // What takes the popup out of sight: its own size, plus the gap a margin keeps
77362
+ // between it and the edge it is pushed through — the room applyNewPosition
77363
+ // publishes on that side. Travelling its size alone, a popup kept off its edge
77364
+ // (a toast 12px above a bar) would stop with that much of itself still showing.
77365
+ const distanceOut = (element, side) => {
77304
77366
  const rect = element.getBoundingClientRect();
77305
- return axis === "x" ? rect.width : rect.height;
77367
+ const size = SWIPE_AXIS_BY_SIDE[side] === "x" ? rect.width : rect.height;
77368
+ const room = parseFloat(
77369
+ element.style.getPropertyValue(`--container-position-room-${side}`),
77370
+ );
77371
+ return Number.isNaN(room) ? size : size + room;
77306
77372
  };
77307
77373
  const translateOf = (axis, distance) =>
77308
77374
  axis === "x"
77309
77375
  ? `translate(${distance}px, 0px)`
77310
77376
  : `translate(0px, ${distance}px)`;
77311
77377
  // The same cut the entry/exit animation makes (popup_css.js), at a distance the
77312
- // finger decides instead of a transition: the edge the popup is pushed back
77313
- // through, as popup_css.js placed it (--x-popup-cut-*-at-area — on the area's
77314
- // edge where a band of glass lies past it, 100vmax out where the window's own
77315
- // edge does the cutting), plus what has been pulled, so the cut holds that line
77316
- // while the box travels under it. The three other sides are left far outside
77317
- // the box — the pull never takes the popup past them, and cutting there would
77318
- // only shave what it legitimately paints outside its own box. Reading the
77319
- // policy from popup_css.js rather than the rooms keeps the release travel
77320
- // (a Web Animation on transform AND clip-path, two clocks) from animating a
77321
- // cut the entrance would not.
77378
+ // finger decides instead of a transition: each edge the popup may leave by on
77379
+ // the axis it travels, as popup_css.js placed it (--x-popup-cut-*-at-area — on
77380
+ // the area's edge where a band of glass lies past it, 100vmax out where the
77381
+ // window's own edge does the cutting), moved by what has been pulled towards
77382
+ // it, so the cut holds that line while the box travels under it, whichever way.
77383
+ // Every other side is left far outside the box — the pull never takes the popup
77384
+ // past them, and cutting there would only shave what it legitimately paints
77385
+ // outside its own box. Reading the policy from popup_css.js rather than the
77386
+ // rooms keeps the release travel (a Web Animation on transform AND clip-path,
77387
+ // two clocks) from animating a cut the entrance would not.
77322
77388
  const UNCUT = "-100vmax";
77323
- const clipOf = (side, distance) => {
77324
- const pulled = distance < 0 ? -distance : distance;
77325
- const cut = `calc(var(--x-popup-cut-${side}-at-area, ${UNCUT}) + ${pulled}px)`;
77326
- if (side === "top") {
77327
- return `inset(${cut} ${UNCUT} ${UNCUT} ${UNCUT})`;
77328
- }
77329
- if (side === "right") {
77330
- return `inset(${UNCUT} ${cut} ${UNCUT} ${UNCUT})`;
77331
- }
77332
- if (side === "bottom") {
77333
- return `inset(${UNCUT} ${UNCUT} ${cut} ${UNCUT})`;
77334
- }
77335
- return `inset(${UNCUT} ${UNCUT} ${UNCUT} ${cut})`;
77389
+ const INSET_ORDER = ["top", "right", "bottom", "left"];
77390
+ const clipOf = (sides, axis, distance) => {
77391
+ const insets = INSET_ORDER.map((side) => {
77392
+ if (!sides.includes(side) || SWIPE_AXIS_BY_SIDE[side] !== axis) {
77393
+ return UNCUT;
77394
+ }
77395
+ const towards = distance * CLOSE_DIRECTION_BY_SIDE[side];
77396
+ return `calc(var(--x-popup-cut-${side}-at-area, ${UNCUT}) + ${towards}px)`;
77397
+ });
77398
+ return `inset(${insets.join(" ")})`;
77336
77399
  };
77337
77400
 
77338
77401
  /**
@@ -79122,7 +79185,7 @@ const useDialogProps = props => {
79122
79185
  // actually resting on an edge: anywhere else the gesture would send the
79123
79186
  // dialog somewhere it never came from.
79124
79187
  const swipeToCloseSide = isDocked && (parsedPositionArea.y === "top" || parsedPositionArea.y === "bottom") ? parsedPositionArea.y : null;
79125
- const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose(swipeToCloseSide, {
79188
+ const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose([swipeToCloseSide], {
79126
79189
  grip: DOCKED_SWIPE_GRIP
79127
79190
  }) : null;
79128
79191
  // A corner sitting exactly on the container's own corner must not be
@@ -80302,6 +80365,16 @@ const css$C = /* css */`
80302
80365
  to filter). An entrance animation moves the popover through scale and
80303
80366
  transform instead, which compose under it: see popup_css.js. */
80304
80367
 
80368
+ /* What a touch may do on a popover pushed sideways to close it (see
80369
+ swipe_to_close.js): the push takes the sideways axis, the page keeps the
80370
+ vertical scroll. A push up or down travels the way the page scrolls and
80371
+ cannot give that axis away: the browser keeps it, and the gesture is
80372
+ settled by keepTouchRefusable. */
80373
+ &[data-swipe-to-close~="left"],
80374
+ &[data-swipe-to-close~="right"] {
80375
+ touch-action: pan-y;
80376
+ }
80377
+
80305
80378
  /* The clamped max, not --popover-maxmax-*: that one ignores what the
80306
80379
  placement left available, which reaches here through
80307
80380
  --container-position-remaining-* (see applyNewPosition) — the min()
@@ -80532,6 +80605,20 @@ const css$C = /* css */`
80532
80605
  * overlap with the anchor; wrap it in `inset(...)` (e.g. `inset(top)`,
80533
80606
  * `inset(top-left)`) to overlap the anchor instead (edges touching or
80534
80607
  * inside it).
80608
+ * @param {boolean|Array<"top"|"right"|"bottom"|"left">} [props.closeByDrag=false] -
80609
+ * Pushing the popover out of its container closes it: it follows the pointer
80610
+ * and, when released, finishes leaving or comes back to rest. `true` pushes
80611
+ * it back through the edge it is docked to, the word `positionArea` leads
80612
+ * with (`"bottom"`, `"bottom-start"`: pushed down; `"left-end"`: pushed
80613
+ * left); a centred popover rests on no edge and is not pushed (warned in
80614
+ * dev). A list names the sides it may leave by, and it leaves the way it was
80615
+ * pushed: `["bottom", "left", "right"]` for a toast pushed back down behind
80616
+ * the bar or thrown sideways. A popover placed against an anchor at its
80617
+ * opening is not pushed either way (warned in dev). It is pushed from its
80618
+ * whole surface, so it suits a popover holding little else: a button inside
80619
+ * keeps its tap, and a press that turns into a push swallows the click it
80620
+ * would have left. The close keeps, as a cross's does. See docs/interactions.md
80621
+ * for a swipe on the popover's own element, which does not move it.
80535
80622
  * @param {string} [props.positionAreaFixed] - Overrides `positionArea` once
80536
80623
  * the popover has actually been positioned once, so a live reposition
80537
80624
  * (e.g. anchor moved) doesn't jump to a different side.
@@ -80985,6 +81072,7 @@ const usePopoverProps = props => {
80985
81072
  positionAreaWhenAnchorIsInvalid,
80986
81073
  marginWithContainer = 0,
80987
81074
  container = "app",
81075
+ closeByDrag = false,
80988
81076
  // "close" → pointer press outside closes (a plain close = commit)
80989
81077
  // "cancel" → pointer press outside closes AS A CANCEL (revert)
80990
81078
  // "capture"→ absorb the press, stay open
@@ -81118,6 +81206,7 @@ const usePopoverProps = props => {
81118
81206
  return popupGroup.register(openController);
81119
81207
  }, [popupGroup, openController]);
81120
81208
  const hasBackdrop = pressOutside !== "ignore" && backdrop && !popupGroup;
81209
+ const swipeToCloseSides = resolveSwipeToCloseSides(closeByDrag, positionArea);
81121
81210
  if (!popupGroup && !backdrop && pressOutside === "capture") {
81122
81211
  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".`);
81123
81212
  }
@@ -81267,6 +81356,9 @@ const usePopoverProps = props => {
81267
81356
  }
81268
81357
  const hasAnchorElement = Boolean(anchorElement);
81269
81358
  anchorElementRef.current = anchorElement || null;
81359
+ if (swipeToCloseSides && hasAnchorElement) {
81360
+ 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);
81361
+ }
81270
81362
  // getPositionedParent already resolves to document.documentElement for
81271
81363
  // the via-attribute renderer on its own (popoverEl's own `popover`
81272
81364
  // attribute, present from render regardless of whether it's actually
@@ -81961,6 +82053,7 @@ const usePopoverProps = props => {
81961
82053
  }
81962
82054
  }
81963
82055
  });
82056
+ const swipeToClose = swipeToCloseSides ? createSwipeToClose(swipeToCloseSides) : null;
81964
82057
  Object.assign(contentProps, {
81965
82058
  id,
81966
82059
  tabIndex,
@@ -82039,6 +82132,26 @@ const usePopoverProps = props => {
82039
82132
  onKeyDown?.(e);
82040
82133
  onKeyDownShortcuts(e);
82041
82134
  },
82135
+ ...(swipeToClose ? {
82136
+ "data-swipe-to-close": swipeToCloseSides.join(" "),
82137
+ // The axes it travels on when pushed out, said to the shared gesture
82138
+ // layer before any finger lands (see side_panel.jsx, which says the
82139
+ // same for the same reasons).
82140
+ "data-drag-travel": swipeAxesOf(swipeToCloseSides),
82141
+ "data-travel-by-drag": swipeAxesOf(swipeToCloseSides),
82142
+ "onTouchMove": e => {
82143
+ rest.onTouchMove?.(e);
82144
+ keepTouchRefusable(e);
82145
+ },
82146
+ "onPointerDown": e => {
82147
+ rest.onPointerDown?.(e);
82148
+ // Placed against an anchor at this opening: it is not in its
82149
+ // container to be pushed out of (warned at the opening).
82150
+ if (!anchorElementRef.current) {
82151
+ swipeToClose(e);
82152
+ }
82153
+ }
82154
+ } : null),
82042
82155
  // A popup opened from inside this one is in front of it, not a peer of
82043
82156
  // it: the group stops here.
82044
82157
  "children": popupGroup ? jsx(PopupGroupContext.Provider, {
@@ -82112,6 +82225,37 @@ const resolvePositionAreaAndAnimationKind = ({
82112
82225
  Popover.Close = PopupClose;
82113
82226
  Popover.Title = PopupTitle;
82114
82227
 
82228
+ // The sides a popover pushed away leaves by, or null when it is not pushed.
82229
+ const resolveSwipeToCloseSides = (closeByDrag, positionArea) => {
82230
+ if (!closeByDrag) {
82231
+ return null;
82232
+ }
82233
+ if (Array.isArray(closeByDrag)) {
82234
+ const sides = closeByDrag.filter(side => side in SWIPE_AXIS_BY_SIDE);
82235
+ if (sides.length !== closeByDrag.length) {
82236
+ console.warn(`Popover: closeByDrag={${JSON.stringify(closeByDrag)}} names something that is not a side; the sides are "top", "right", "bottom" and "left".`);
82237
+ }
82238
+ return sides.length ? sides : null;
82239
+ }
82240
+ const side = dockedSideOf(positionArea);
82241
+ if (!side) {
82242
+ {
82243
+ 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"]}.`);
82244
+ }
82245
+ return null;
82246
+ }
82247
+ return [side];
82248
+ };
82249
+ const INSET_TOKEN_RE = /^inset\(\s*([a-z-]+)\s*\)$/;
82250
+ // The edge a popover docked to its container rests on: the word its
82251
+ // positionArea leads with, "bottom-start" resting on the bottom and "left-end"
82252
+ // on the left. "center" rests on none.
82253
+ const dockedSideOf = positionArea => {
82254
+ const token = INSET_TOKEN_RE.exec(positionArea)?.[1] ?? positionArea;
82255
+ const side = token.split("-")[0];
82256
+ return side in SWIPE_AXIS_BY_SIDE ? side : null;
82257
+ };
82258
+
82115
82259
  // The placement needs pixels: a value only CSS could evaluate (a percentage, a
82116
82260
  // calc()…) would put the popover at NaN, which is the top of the page.
82117
82261
  const resolveMarginInPixels = (margin, name) => {
@@ -92640,8 +92784,19 @@ const useItemStore = ({
92640
92784
  // was asked for, and the window keeps growing meanwhile: dropping what the
92641
92785
  // page did not cover empties items on screen.
92642
92786
  const staleRanksRef = useRef(null);
92787
+ // A run standing for no item has no rank to mark: its count is what is from
92788
+ // before, and no window frames anything that would ask for it again.
92789
+ const countStaleRef = useRef(false);
92790
+ const markHeldStale = () => {
92791
+ staleRanksRef.current = new Set(pages.byIndex.keys());
92792
+ countStaleRef.current = pages.count === 0;
92793
+ };
92643
92794
  if (staleRanksRef.current === null) {
92644
- staleRanksRef.current = restored ? new Set(pages.byIndex.keys()) : new Set();
92795
+ if (restored) {
92796
+ markHeldStale();
92797
+ } else {
92798
+ staleRanksRef.current = new Set();
92799
+ }
92645
92800
  }
92646
92801
  const takeInPage = ({
92647
92802
  pageItems,
@@ -92680,6 +92835,7 @@ const useItemStore = ({
92680
92835
  i++;
92681
92836
  }
92682
92837
  pages.count = pageCount;
92838
+ countStaleRef.current = false;
92683
92839
  // Which rank holds which id, kept by the source so a list drawing
92684
92840
  // this collection again finds it drawn (see readComposition above).
92685
92841
  if (itemsAction.writeComposition) {
@@ -92715,7 +92871,7 @@ const useItemStore = ({
92715
92871
  const invalidationRef = useRef(invalidation);
92716
92872
  if (invalidationRef.current !== invalidation) {
92717
92873
  invalidationRef.current = invalidation;
92718
- staleRanksRef.current = new Set(pages.byIndex.keys());
92874
+ markHeldStale();
92719
92875
  }
92720
92876
  // The one request in flight, with the means to call it off: a page asked for
92721
92877
  // a window the list has left is work the server and the browser are doing for
@@ -92913,7 +93069,8 @@ const useItemStore = ({
92913
93069
  rank++;
92914
93070
  }
92915
93071
  }
92916
- const revalidating = staleFrom !== -1;
93072
+ const countStale = countStaleRef.current;
93073
+ const revalidating = staleFrom !== -1 || countStale;
92917
93074
  // The list is held on an item nothing on screen leads to: the items it holds
92918
93075
  // do not contain it, so no window it could draw will ever bring it. Only
92919
93076
  // asking for it by name does.
@@ -92929,6 +93086,10 @@ const useItemStore = ({
92929
93086
  const from = typeof wanted.index === "number" ? rankOfIndex(wanted.index) - Math.floor(budget / 2) : 0;
92930
93087
  start = from < 0 ? 0 : from;
92931
93088
  end = start + budget - 1;
93089
+ } else if (countStale) {
93090
+ // Nothing drawn to anchor on: the first page, like the very first ask.
93091
+ start = 0;
93092
+ end = budget - 1;
92932
93093
  } else if (revalidating) {
92933
93094
  // The items from before that the window draws, and the missing ones
92934
93095
  // with them: one ask.
@@ -93130,6 +93291,7 @@ const useItemStore = ({
93130
93291
  staleRanksRef.current.delete(rank);
93131
93292
  rank++;
93132
93293
  }
93294
+ countStaleRef.current = false;
93133
93295
  setRefreshing(false);
93134
93296
  return;
93135
93297
  }
@@ -110173,7 +110335,7 @@ const SidePanel = ({
110173
110335
  }) => {
110174
110336
  import.meta.css = [css, "@jsenv/navi/src/layout/side_panel.jsx"];
110175
110337
  const rest = dropPickerPopupProps(restGiven, "SidePanel");
110176
- const onSwipePointerDown = closeByDrag ? createSwipeToClose(side) : null;
110338
+ const onSwipePointerDown = closeByDrag ? createSwipeToClose([side]) : null;
110177
110339
  return jsx(Popup, {
110178
110340
  mode: mode
110179
110341
  // Spread rather than written: the collision warning between `signal` and