@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.
@@ -29381,8 +29381,9 @@ const debug$2 = (...args) => {
29381
29381
  * policy as that verb, and is a write of its own for a dependent:
29382
29382
  * `{ resource: ORDER, on: ["cancel"] }`. Its `rerunOn` says which reads of
29383
29383
  * this resource it reruns (`{ GET_RANGE: true }`); a read it leaves out
29384
- * follows `rerunOn` for its verb. A name a verb takes, or one the resource
29385
- * already exposes (`withParams`, `store`…), throws. A `.scopedOne()` or
29384
+ * follows `rerunOn` for its verb. A verb's name in any case (`post`,
29385
+ * `get_many`…), or one the resource already exposes (`withParams`, `store`…),
29386
+ * throws. A `.scopedOne()` or
29386
29387
  * `.scopedMany()` takes `writes` too, `.scopedMany()` with the plural verbs.
29387
29388
  * @param {{ GET?: { ms?: number, max?: number }, GET_MANY?: { ms?: number, max?: number } }} [restCallbacks.keep] -
29388
29389
  * how long an answer of a read is trusted, coming back to the same params
@@ -30938,12 +30939,15 @@ ${name}.many("${propertyName}") source location: ${declarationSite}`,
30938
30939
 
30939
30940
  // expose one action per write named in `writes`
30940
30941
  if (writes) {
30942
+ const membersExposed = Object.keys(stateFacade).filter(
30943
+ (key) => !VERB_KEYS.includes(key),
30944
+ );
30941
30945
  for (const [writeName, write] of Object.entries(writes)) {
30942
30946
  assertWriteCanBeNamed(
30943
30947
  name,
30944
30948
  writeName,
30945
30949
  write,
30946
- stateFacade,
30950
+ membersExposed,
30947
30951
  namedWriteVerbs,
30948
30952
  );
30949
30953
  const { verb: writeVerb, callback, rerunOn: writeRerunOn } = write;
@@ -31256,7 +31260,7 @@ const assertWriteCanBeNamed = (
31256
31260
  name,
31257
31261
  writeName,
31258
31262
  write,
31259
- stateFacade,
31263
+ membersExposed,
31260
31264
  namedWriteVerbs,
31261
31265
  ) => {
31262
31266
  const where = `resource(${name}) writes.${writeName}`;
@@ -31265,9 +31269,9 @@ const assertWriteCanBeNamed = (
31265
31269
  `${where}: "${writeName}" is a verb's name — a write named in "writes" takes a name of its own (cancel, archive…)`,
31266
31270
  );
31267
31271
  }
31268
- if (Object.hasOwn(stateFacade, writeName)) {
31272
+ if (membersExposed.includes(writeName)) {
31269
31273
  throw new Error(
31270
- `${where}: the resource already exposes "${writeName}" — pick another name`,
31274
+ `${where}: the resource already exposes "${writeName}" — a write takes a name none of its members has: ${membersExposed.join(", ")}`,
31271
31275
  );
31272
31276
  }
31273
31277
  if (!write || typeof write !== "object") {
@@ -75472,18 +75476,19 @@ const readRoom = () =>
75472
75476
  visualViewportHeightSignal.peek() - getVirtualKeyboardOverlayHeight();
75473
75477
 
75474
75478
  /**
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.
75479
+ * Pushing a popup docked to an edge (a side panel, a bottom sheet, a toast)
75480
+ * back the way it came in closes it: it follows the finger while it travels,
75481
+ * and letting go either finishes the departure or brings it back to rest —
75482
+ * whichever the travel was already heading to. A popup may be given other ways
75483
+ * out than that one: a toast is also thrown sideways.
75479
75484
  *
75480
75485
  * Reading the pointer is not done here: when a press becomes a gesture, which
75481
75486
  * axis it leans on, how fast it was going when it was let go, who else has a
75482
75487
  * claim on it (a field, a scroller with room left, a box inside the panel that
75483
75488
  * travels the same way) is one gesture system for the whole codebase
75484
75489
  * (@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.
75490
+ * box, out by one of the edges it may leave by and nowhere else — which is all
75491
+ * this file has to say.
75487
75492
  *
75488
75493
  * The release travel is driven here (Web Animations) rather than handed to the
75489
75494
  * exit transition the `animation` prop may install: its pace is what is left to
@@ -75498,7 +75503,7 @@ const readRoom = () =>
75498
75503
  // less, so the panel keeps the same speed whenever the finger let go.
75499
75504
  const TRAVEL_DURATION = 220;
75500
75505
 
75501
- // Which way a popup docked to each side travels — read by the panel itself to
75506
+ // Which way a popup leaving by each side travels — read by the panel itself to
75502
75507
  // say so in the DOM, which is how a box travelling INSIDE it (a row of slides,
75503
75508
  // a route travel) knows that axis is already walked.
75504
75509
  const SWIPE_AXIS_BY_SIDE = {
@@ -75507,12 +75512,27 @@ const SWIPE_AXIS_BY_SIDE = {
75507
75512
  top: "y",
75508
75513
  bottom: "y",
75509
75514
  };
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.
75515
+ // Which way the finger pushes to send the panel out by each side, in screen
75516
+ // coordinates: positive is right/down, the same sign the gesture reports.
75512
75517
  const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
75513
75518
 
75514
75519
  /**
75515
- * Builds the `pointerdown` handler a popup docked to `side` answers with.
75520
+ * The axes a popup leaving by `sides` travels on, as the gesture and the DOM
75521
+ * say them: "x", "y" or "xy".
75522
+ */
75523
+ const swipeAxesOf = (sides) => {
75524
+ const hasX = sides.some((side) => SWIPE_AXIS_BY_SIDE[side] === "x");
75525
+ const hasY = sides.some((side) => SWIPE_AXIS_BY_SIDE[side] === "y");
75526
+ if (hasX && hasY) {
75527
+ return "xy";
75528
+ }
75529
+ return hasX ? "x" : "y";
75530
+ };
75531
+
75532
+ /**
75533
+ * Builds the `pointerdown` handler of a popup that leaves by any of `sides`:
75534
+ * usually the one edge it is docked to, the way it came in; a toast is also
75535
+ * thrown sideways, and leaves the way it was thrown.
75516
75536
  *
75517
75537
  * `grip` says where the gesture may start, as a selector the pressed element is
75518
75538
  * matched against with `closest`. A popup that names one is held THERE and
@@ -75520,11 +75540,17 @@ const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
75520
75540
  * and a press on it is that content's — including gestures of its own, which
75521
75541
  * this file has no way of knowing about. A popup that names no grip is pushed
75522
75542
  * 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];
75543
+ * panel showing a page, a toast).
75544
+ */
75545
+ const createSwipeToClose = (sides, { grip } = {}) => {
75546
+ const axes = swipeAxesOf(sides);
75547
+ // The side a push along `axis` towards `sign` leaves by, if it leaves at all.
75548
+ const sideTowards = (axis, sign) =>
75549
+ sides.find(
75550
+ (side) =>
75551
+ SWIPE_AXIS_BY_SIDE[side] === axis &&
75552
+ CLOSE_DIRECTION_BY_SIDE[side] === sign,
75553
+ );
75528
75554
 
75529
75555
  return (pointerDownEvent) => {
75530
75556
  const panelEl = pointerDownEvent.currentTarget;
@@ -75541,6 +75567,11 @@ const createSwipeToClose = (side, { grip } = {}) => {
75541
75567
  }
75542
75568
  }
75543
75569
 
75570
+ // Decided when the finger picks its axis (onStart), once for the gesture.
75571
+ let axis;
75572
+ // How far the panel travels to be out, for each way it can go on that axis.
75573
+ const distanceOutBySign = new Map();
75574
+
75544
75575
  // How far the panel has been pulled, as the style showing it — written on
75545
75576
  // the panel while the finger drives it, handed to the release travel as
75546
75577
  // its two keyframes. The gesture reports a distance in screen coordinates,
@@ -75550,7 +75581,7 @@ const createSwipeToClose = (side, { grip } = {}) => {
75550
75581
  // composes under the placement instead of replacing it.
75551
75582
  const styleAt = (distance) => ({
75552
75583
  transform: translateOf(axis, distance),
75553
- clipPath: clipOf(side, distance),
75584
+ clipPath: clipOf(sides, axis, distance),
75554
75585
  });
75555
75586
  const paint = (distance) => {
75556
75587
  Object.assign(panelEl.style, styleAt(distance));
@@ -75562,21 +75593,22 @@ const createSwipeToClose = (side, { grip } = {}) => {
75562
75593
  };
75563
75594
  // The resting value is written first and the animation only covers the way
75564
75595
  // 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) => {
75596
+ // animation hands the panel back to its own style. `span` is the full travel
75597
+ // on that side, which the duration is a fraction of.
75598
+ const travelTo = (from, to, span, onArrival) => {
75567
75599
  const covered = from > to ? from - to : to - from;
75568
75600
  paint(to);
75569
75601
  const animation = panelEl.animate([styleAt(from), styleAt(to)], {
75570
- duration: (covered / sizeOf(panelEl, axis)) * TRAVEL_DURATION,
75602
+ duration: (covered / span) * TRAVEL_DURATION,
75571
75603
  easing: "ease-out",
75572
75604
  });
75573
75605
  animation.finished.then(onArrival, () => {});
75574
75606
  };
75575
- const close = (event) => {
75607
+ const close = (event, out, span) => {
75576
75608
  triggerNaviCommand(panelEl, "--navi-close", event);
75577
75609
  if (panelEl.getAttribute("aria-expanded") === "true") {
75578
75610
  // Refused: the panel is staying, so it goes back where it was.
75579
- travelTo(sizeOf(panelEl, axis) * closeDirection, 0, restore);
75611
+ travelTo(out, 0, span, restore);
75580
75612
  return;
75581
75613
  }
75582
75614
  restore();
@@ -75584,79 +75616,94 @@ const createSwipeToClose = (side, { grip } = {}) => {
75584
75616
 
75585
75617
  startDragToTravel(pointerDownEvent, {
75586
75618
  element: panelEl,
75587
- axes: axis,
75588
- onStart: ({ sign, target }) => {
75589
- if (sign !== closeDirection) {
75619
+ axes,
75620
+ onStart: ({ axis: gestureAxis, sign, target }) => {
75621
+ const side = sideTowards(gestureAxis, sign);
75622
+ if (!side) {
75590
75623
  // Pushing the panel further in is not a travel: there is nowhere that
75591
75624
  // way, and reading it as one would make the panel lean at every
75592
75625
  // gesture that starts by going the wrong way.
75593
75626
  return false;
75594
75627
  }
75595
- const size = sizeOf(panelEl, axis);
75628
+ const size = distanceOut(panelEl, side);
75596
75629
  if (!size) {
75597
75630
  return false;
75598
75631
  }
75599
75632
  // Something else with a better claim on the gesture: a scroller between
75600
75633
  // the finger and the panel, with room left that way.
75601
- if (scrollRoomTowards(target, panelEl, axis, sign)) {
75634
+ if (scrollRoomTowards(target, panelEl, gestureAxis, sign)) {
75602
75635
  return false;
75603
75636
  }
75637
+ axis = gestureAxis;
75638
+ distanceOutBySign.set(sign, size);
75639
+ const otherSide = sideTowards(gestureAxis, -sign);
75640
+ if (otherSide) {
75641
+ distanceOutBySign.set(-sign, distanceOut(panelEl, otherSide));
75642
+ }
75604
75643
  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.
75644
+ // Only the ways out lead anywhere: pulling towards a side the panel
75645
+ // does not leave by leans against a wall and comes back.
75607
75646
  return {
75608
75647
  size,
75609
- travelBack: closeDirection > 0,
75610
- travelOn: closeDirection < 0,
75648
+ travelBack: Boolean(sideTowards(gestureAxis, 1)),
75649
+ travelOn: Boolean(sideTowards(gestureAxis, -1)),
75611
75650
  };
75612
75651
  },
75613
75652
  onPull: ({ pulled }) => {
75614
75653
  paint(pulled);
75615
75654
  },
75616
- onEnd: ({ pulled, size, travels, event }) => {
75655
+ onEnd: ({ pulled, size, sign, travels, event }) => {
75617
75656
  if (travels) {
75618
- travelTo(pulled, size * closeDirection, () => close(event));
75657
+ const span = distanceOutBySign.get(sign);
75658
+ travelTo(pulled, span * sign, span, () =>
75659
+ close(event, span * sign, span),
75660
+ );
75619
75661
  return;
75620
75662
  }
75621
- travelTo(pulled, 0, restore);
75663
+ travelTo(pulled, 0, size, restore);
75622
75664
  },
75623
75665
  });
75624
75666
  };
75625
75667
  };
75626
75668
 
75627
- const sizeOf = (element, axis) => {
75669
+ // What takes the popup out of sight: its own size, plus the gap a margin keeps
75670
+ // between it and the edge it is pushed through — the room applyNewPosition
75671
+ // publishes on that side. Travelling its size alone, a popup kept off its edge
75672
+ // (a toast 12px above a bar) would stop with that much of itself still showing.
75673
+ const distanceOut = (element, side) => {
75628
75674
  const rect = element.getBoundingClientRect();
75629
- return axis === "x" ? rect.width : rect.height;
75675
+ const size = SWIPE_AXIS_BY_SIDE[side] === "x" ? rect.width : rect.height;
75676
+ const room = parseFloat(
75677
+ element.style.getPropertyValue(`--container-position-room-${side}`),
75678
+ );
75679
+ return Number.isNaN(room) ? size : size + room;
75630
75680
  };
75631
75681
  const translateOf = (axis, distance) =>
75632
75682
  axis === "x"
75633
75683
  ? `translate(${distance}px, 0px)`
75634
75684
  : `translate(0px, ${distance}px)`;
75635
75685
  // 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.
75686
+ // finger decides instead of a transition: each edge the popup may leave by on
75687
+ // the axis it travels, as popup_css.js placed it (--x-popup-cut-*-at-area — on
75688
+ // the area's edge where a band of glass lies past it, 100vmax out where the
75689
+ // window's own edge does the cutting), moved by what has been pulled towards
75690
+ // it, so the cut holds that line while the box travels under it, whichever way.
75691
+ // Every other side is left far outside the box — the pull never takes the popup
75692
+ // past them, and cutting there would only shave what it legitimately paints
75693
+ // outside its own box. Reading the policy from popup_css.js rather than the
75694
+ // rooms keeps the release travel (a Web Animation on transform AND clip-path,
75695
+ // two clocks) from animating a cut the entrance would not.
75646
75696
  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})`;
75697
+ const INSET_ORDER = ["top", "right", "bottom", "left"];
75698
+ const clipOf = (sides, axis, distance) => {
75699
+ const insets = INSET_ORDER.map((side) => {
75700
+ if (!sides.includes(side) || SWIPE_AXIS_BY_SIDE[side] !== axis) {
75701
+ return UNCUT;
75702
+ }
75703
+ const towards = distance * CLOSE_DIRECTION_BY_SIDE[side];
75704
+ return `calc(var(--x-popup-cut-${side}-at-area, ${UNCUT}) + ${towards}px)`;
75705
+ });
75706
+ return `inset(${insets.join(" ")})`;
75660
75707
  };
75661
75708
 
75662
75709
  /**
@@ -77438,7 +77485,7 @@ const useDialogProps = props => {
77438
77485
  // actually resting on an edge: anywhere else the gesture would send the
77439
77486
  // dialog somewhere it never came from.
77440
77487
  const swipeToCloseSide = isDocked && (parsedPositionArea.y === "top" || parsedPositionArea.y === "bottom") ? parsedPositionArea.y : null;
77441
- const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose(swipeToCloseSide, {
77488
+ const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose([swipeToCloseSide], {
77442
77489
  grip: DOCKED_SWIPE_GRIP
77443
77490
  }) : null;
77444
77491
  // A corner sitting exactly on the container's own corner must not be
@@ -78531,6 +78578,16 @@ const css$C = /* css */`
78531
78578
  to filter). An entrance animation moves the popover through scale and
78532
78579
  transform instead, which compose under it: see popup_css.js. */
78533
78580
 
78581
+ /* What a touch may do on a popover pushed sideways to close it (see
78582
+ swipe_to_close.js): the push takes the sideways axis, the page keeps the
78583
+ vertical scroll. A push up or down travels the way the page scrolls and
78584
+ cannot give that axis away: the browser keeps it, and the gesture is
78585
+ settled by keepTouchRefusable. */
78586
+ &[data-swipe-to-close~="left"],
78587
+ &[data-swipe-to-close~="right"] {
78588
+ touch-action: pan-y;
78589
+ }
78590
+
78534
78591
  /* The clamped max, not --popover-maxmax-*: that one ignores what the
78535
78592
  placement left available, which reaches here through
78536
78593
  --container-position-remaining-* (see applyNewPosition) — the min()
@@ -78761,6 +78818,20 @@ const css$C = /* css */`
78761
78818
  * overlap with the anchor; wrap it in `inset(...)` (e.g. `inset(top)`,
78762
78819
  * `inset(top-left)`) to overlap the anchor instead (edges touching or
78763
78820
  * inside it).
78821
+ * @param {boolean|Array<"top"|"right"|"bottom"|"left">} [props.closeByDrag=false] -
78822
+ * Pushing the popover out of its container closes it: it follows the pointer
78823
+ * and, when released, finishes leaving or comes back to rest. `true` pushes
78824
+ * it back through the edge it is docked to, the word `positionArea` leads
78825
+ * with (`"bottom"`, `"bottom-start"`: pushed down; `"left-end"`: pushed
78826
+ * left); a centred popover rests on no edge and is not pushed (warned in
78827
+ * dev). A list names the sides it may leave by, and it leaves the way it was
78828
+ * pushed: `["bottom", "left", "right"]` for a toast pushed back down behind
78829
+ * the bar or thrown sideways. A popover placed against an anchor at its
78830
+ * opening is not pushed either way (warned in dev). It is pushed from its
78831
+ * whole surface, so it suits a popover holding little else: a button inside
78832
+ * keeps its tap, and a press that turns into a push swallows the click it
78833
+ * would have left. The close keeps, as a cross's does. See docs/interactions.md
78834
+ * for a swipe on the popover's own element, which does not move it.
78764
78835
  * @param {string} [props.positionAreaFixed] - Overrides `positionArea` once
78765
78836
  * the popover has actually been positioned once, so a live reposition
78766
78837
  * (e.g. anchor moved) doesn't jump to a different side.
@@ -79212,6 +79283,7 @@ const usePopoverProps = props => {
79212
79283
  positionAreaWhenAnchorIsInvalid,
79213
79284
  marginWithContainer = 0,
79214
79285
  container = "app",
79286
+ closeByDrag = false,
79215
79287
  // "close" → pointer press outside closes (a plain close = commit)
79216
79288
  // "cancel" → pointer press outside closes AS A CANCEL (revert)
79217
79289
  // "capture"→ absorb the press, stay open
@@ -79339,6 +79411,7 @@ const usePopoverProps = props => {
79339
79411
  return popupGroup.register(openController);
79340
79412
  }, [popupGroup, openController]);
79341
79413
  const hasBackdrop = pressOutside !== "ignore" && backdrop && !popupGroup;
79414
+ const swipeToCloseSides = resolveSwipeToCloseSides(closeByDrag, positionArea);
79342
79415
  if (!popupGroup && !backdrop && pressOutside === "capture") {
79343
79416
  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
79417
  }
@@ -80172,6 +80245,7 @@ const usePopoverProps = props => {
80172
80245
  }
80173
80246
  }
80174
80247
  });
80248
+ const swipeToClose = swipeToCloseSides ? createSwipeToClose(swipeToCloseSides) : null;
80175
80249
  Object.assign(contentProps, {
80176
80250
  id,
80177
80251
  tabIndex,
@@ -80250,6 +80324,26 @@ const usePopoverProps = props => {
80250
80324
  onKeyDown?.(e);
80251
80325
  onKeyDownShortcuts(e);
80252
80326
  },
80327
+ ...(swipeToClose ? {
80328
+ "data-swipe-to-close": swipeToCloseSides.join(" "),
80329
+ // The axes it travels on when pushed out, said to the shared gesture
80330
+ // layer before any finger lands (see side_panel.jsx, which says the
80331
+ // same for the same reasons).
80332
+ "data-drag-travel": swipeAxesOf(swipeToCloseSides),
80333
+ "data-travel-by-drag": swipeAxesOf(swipeToCloseSides),
80334
+ "onTouchMove": e => {
80335
+ rest.onTouchMove?.(e);
80336
+ keepTouchRefusable(e);
80337
+ },
80338
+ "onPointerDown": e => {
80339
+ rest.onPointerDown?.(e);
80340
+ // Placed against an anchor at this opening: it is not in its
80341
+ // container to be pushed out of (warned at the opening).
80342
+ if (!anchorElementRef.current) {
80343
+ swipeToClose(e);
80344
+ }
80345
+ }
80346
+ } : null),
80253
80347
  // A popup opened from inside this one is in front of it, not a peer of
80254
80348
  // it: the group stops here.
80255
80349
  "children": popupGroup ? jsx(PopupGroupContext.Provider, {
@@ -80323,6 +80417,31 @@ const resolvePositionAreaAndAnimationKind = ({
80323
80417
  Popover.Close = PopupClose;
80324
80418
  Popover.Title = PopupTitle;
80325
80419
 
80420
+ // The sides a popover pushed away leaves by, or null when it is not pushed.
80421
+ const resolveSwipeToCloseSides = (closeByDrag, positionArea) => {
80422
+ if (!closeByDrag) {
80423
+ return null;
80424
+ }
80425
+ if (Array.isArray(closeByDrag)) {
80426
+ const sides = closeByDrag.filter(side => side in SWIPE_AXIS_BY_SIDE);
80427
+ return sides.length ? sides : null;
80428
+ }
80429
+ const side = dockedSideOf(positionArea);
80430
+ if (!side) {
80431
+ return null;
80432
+ }
80433
+ return [side];
80434
+ };
80435
+ const INSET_TOKEN_RE = /^inset\(\s*([a-z-]+)\s*\)$/;
80436
+ // The edge a popover docked to its container rests on: the word its
80437
+ // positionArea leads with, "bottom-start" resting on the bottom and "left-end"
80438
+ // on the left. "center" rests on none.
80439
+ const dockedSideOf = positionArea => {
80440
+ const token = INSET_TOKEN_RE.exec(positionArea)?.[1] ?? positionArea;
80441
+ const side = token.split("-")[0];
80442
+ return side in SWIPE_AXIS_BY_SIDE ? side : null;
80443
+ };
80444
+
80326
80445
  // The placement needs pixels: a value only CSS could evaluate (a percentage, a
80327
80446
  // calc()…) would put the popover at NaN, which is the top of the page.
80328
80447
  const resolveMarginInPixels = (margin, name) => {
@@ -90629,8 +90748,19 @@ const useItemStore = ({
90629
90748
  // was asked for, and the window keeps growing meanwhile: dropping what the
90630
90749
  // page did not cover empties items on screen.
90631
90750
  const staleRanksRef = useRef(null);
90751
+ // A run standing for no item has no rank to mark: its count is what is from
90752
+ // before, and no window frames anything that would ask for it again.
90753
+ const countStaleRef = useRef(false);
90754
+ const markHeldStale = () => {
90755
+ staleRanksRef.current = new Set(pages.byIndex.keys());
90756
+ countStaleRef.current = pages.count === 0;
90757
+ };
90632
90758
  if (staleRanksRef.current === null) {
90633
- staleRanksRef.current = restored ? new Set(pages.byIndex.keys()) : new Set();
90759
+ if (restored) {
90760
+ markHeldStale();
90761
+ } else {
90762
+ staleRanksRef.current = new Set();
90763
+ }
90634
90764
  }
90635
90765
  const takeInPage = ({
90636
90766
  pageItems,
@@ -90669,6 +90799,7 @@ const useItemStore = ({
90669
90799
  i++;
90670
90800
  }
90671
90801
  pages.count = pageCount;
90802
+ countStaleRef.current = false;
90672
90803
  // Which rank holds which id, kept by the source so a list drawing
90673
90804
  // this collection again finds it drawn (see readComposition above).
90674
90805
  if (itemsAction.writeComposition) {
@@ -90704,7 +90835,7 @@ const useItemStore = ({
90704
90835
  const invalidationRef = useRef(invalidation);
90705
90836
  if (invalidationRef.current !== invalidation) {
90706
90837
  invalidationRef.current = invalidation;
90707
- staleRanksRef.current = new Set(pages.byIndex.keys());
90838
+ markHeldStale();
90708
90839
  }
90709
90840
  // The one request in flight, with the means to call it off: a page asked for
90710
90841
  // a window the list has left is work the server and the browser are doing for
@@ -90902,7 +91033,8 @@ const useItemStore = ({
90902
91033
  rank++;
90903
91034
  }
90904
91035
  }
90905
- const revalidating = staleFrom !== -1;
91036
+ const countStale = countStaleRef.current;
91037
+ const revalidating = staleFrom !== -1 || countStale;
90906
91038
  // The list is held on an item nothing on screen leads to: the items it holds
90907
91039
  // do not contain it, so no window it could draw will ever bring it. Only
90908
91040
  // asking for it by name does.
@@ -90918,6 +91050,10 @@ const useItemStore = ({
90918
91050
  const from = typeof wanted.index === "number" ? rankOfIndex(wanted.index) - Math.floor(budget / 2) : 0;
90919
91051
  start = from < 0 ? 0 : from;
90920
91052
  end = start + budget - 1;
91053
+ } else if (countStale) {
91054
+ // Nothing drawn to anchor on: the first page, like the very first ask.
91055
+ start = 0;
91056
+ end = budget - 1;
90921
91057
  } else if (revalidating) {
90922
91058
  // The items from before that the window draws, and the missing ones
90923
91059
  // with them: one ask.
@@ -91119,6 +91255,7 @@ const useItemStore = ({
91119
91255
  staleRanksRef.current.delete(rank);
91120
91256
  rank++;
91121
91257
  }
91258
+ countStaleRef.current = false;
91122
91259
  setRefreshing(false);
91123
91260
  return;
91124
91261
  }
@@ -108030,7 +108167,7 @@ const SidePanel = ({
108030
108167
  }) => {
108031
108168
  import.meta.css = [css, "@jsenv/navi/src/layout/side_panel.jsx"];
108032
108169
  const rest = dropPickerPopupProps(restGiven);
108033
- const onSwipePointerDown = closeByDrag ? createSwipeToClose(side) : null;
108170
+ const onSwipePointerDown = closeByDrag ? createSwipeToClose([side]) : null;
108034
108171
  return jsx(Popup, {
108035
108172
  mode: mode
108036
108173
  // Spread rather than written: the collision warning between `signal` and