@jsenv/dom 0.17.45 → 0.17.47

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.
Files changed (2) hide show
  1. package/dist/jsenv_dom.js +288 -147
  2. package/package.json +6 -2
package/dist/jsenv_dom.js CHANGED
@@ -4524,15 +4524,31 @@ const waitForPressHeld = (
4524
4524
  }
4525
4525
  cancelPress(pointerEndEvent);
4526
4526
  };
4527
+ // Somebody else settled what this press is. Taking the pointer is how a gesture
4528
+ // says it — and it says it about the same finger this wait is counting on, so
4529
+ // whatever the press turned out to be, it is not a hold. Two waits on one press
4530
+ // is the ordinary case rather than an odd one: an element that can be picked up
4531
+ // AND held answers a finger with two delays, the shorter one wins, and without
4532
+ // this the longer one would answer a hundred milliseconds into the carry.
4533
+ // The listener goes with the countdown, so the gesture THIS wait starts (which
4534
+ // captures the pointer from inside onPressHeld) never reaches it.
4535
+ const onGotPointerCapture = (captureEvent) => {
4536
+ if (captureEvent.pointerId !== pointerId) {
4537
+ return;
4538
+ }
4539
+ cancelPress(captureEvent);
4540
+ };
4527
4541
  // On window rather than on the element: the finger can leave it, and the
4528
4542
  // element itself can be taken out of the document while the press is waiting.
4529
4543
  window.addEventListener("pointermove", onPointerMove);
4530
4544
  window.addEventListener("pointerup", onPointerEnd);
4531
4545
  window.addEventListener("pointercancel", onPointerEnd);
4546
+ window.addEventListener("gotpointercapture", onGotPointerCapture, true);
4532
4547
  countdownCleanupCallbacks.push(() => {
4533
4548
  window.removeEventListener("pointermove", onPointerMove);
4534
4549
  window.removeEventListener("pointerup", onPointerEnd);
4535
4550
  window.removeEventListener("pointercancel", onPointerEnd);
4551
+ window.removeEventListener("gotpointercapture", onGotPointerCapture, true);
4536
4552
  });
4537
4553
 
4538
4554
  onPressStart?.(pressEvent);
@@ -7515,7 +7531,8 @@ const getScrollbarState = (
7515
7531
  * scrollIntoViewScoped avoids this by targeting one container explicitly.
7516
7532
  *
7517
7533
  * Uses scrollTo() so CSS scroll-behavior:smooth on the container is respected.
7518
- * Respects scroll-margin-* on the element.
7534
+ * Respects scroll-margin-* on the element and scroll-padding-* on the
7535
+ * container.
7519
7536
  *
7520
7537
  * @param {Element} el - The element to scroll into view.
7521
7538
  * @param {object} options
@@ -7621,6 +7638,31 @@ const getScrollIntoViewScopedOffsets = (
7621
7638
  containerWidth = containerRect.width;
7622
7639
  }
7623
7640
 
7641
+ // The band the container keeps free at each of its edges for whatever is
7642
+ // scrolled to — what a sticky header, a footer or a fixed bar reserves so an
7643
+ // element does not land underneath it (see navi's safe_area.js). The
7644
+ // scrollport shrinks by it on both axes, and every alignment below is against
7645
+ // that smaller rectangle.
7646
+ const containerStyle = getComputedStyle(container);
7647
+ const scrollPaddingTop = resolveScrollPadding(
7648
+ containerStyle.scrollPaddingTop,
7649
+ containerHeight,
7650
+ );
7651
+ const scrollPaddingBottom = resolveScrollPadding(
7652
+ containerStyle.scrollPaddingBottom,
7653
+ containerHeight,
7654
+ );
7655
+ const scrollPaddingLeft = resolveScrollPadding(
7656
+ containerStyle.scrollPaddingLeft,
7657
+ containerWidth,
7658
+ );
7659
+ const scrollPaddingRight = resolveScrollPadding(
7660
+ containerStyle.scrollPaddingRight,
7661
+ containerWidth,
7662
+ );
7663
+ const viewHeight = containerHeight - scrollPaddingTop - scrollPaddingBottom;
7664
+ const viewWidth = containerWidth - scrollPaddingLeft - scrollPaddingRight;
7665
+
7624
7666
  // Element position relative to the container's scroll origin.
7625
7667
  const elTop = elRect.top - containerTop + currentScrollTop - scrollMarginTop;
7626
7668
  const elBottom = elTop + elRect.height + scrollMarginTop + scrollMarginBottom;
@@ -7628,55 +7670,67 @@ const getScrollIntoViewScopedOffsets = (
7628
7670
  elRect.left - containerLeft + currentScrollLeft - scrollMarginLeft;
7629
7671
  const elRight = elLeft + elRect.width + scrollMarginLeft + scrollMarginRight;
7630
7672
 
7673
+ // The two scroll positions that put the element against one edge of the view
7674
+ // or the other — every alignment below is one of them, or a point between.
7675
+ const scrollTopPuttingElAtViewStart = elTop - scrollPaddingTop;
7676
+ const scrollTopPuttingElAtViewEnd =
7677
+ elBottom - containerHeight + scrollPaddingBottom;
7678
+ const viewStart = currentScrollTop + scrollPaddingTop;
7679
+ const viewEnd = currentScrollTop + containerHeight - scrollPaddingBottom;
7680
+
7631
7681
  let newScrollTop = currentScrollTop;
7632
7682
  if (block === "start") {
7633
- newScrollTop = elTop;
7683
+ newScrollTop = scrollTopPuttingElAtViewStart;
7634
7684
  } else if (block === "end") {
7635
- newScrollTop = elBottom - containerHeight;
7685
+ newScrollTop = scrollTopPuttingElAtViewEnd;
7636
7686
  } else if (block === "center") {
7637
- newScrollTop = elTop + (elRect.height - containerHeight) / 2;
7687
+ newScrollTop = elTop + (elRect.height - viewHeight) / 2 - scrollPaddingTop;
7638
7688
  } else {
7639
7689
  // nearest: scroll only if partially or fully out of view.
7640
- // When the element is taller than the container, only scroll if it is
7690
+ // When the element is taller than the view, only scroll if it is
7641
7691
  // completely out of view — otherwise it is already as visible as possible.
7642
- const scrollBottom = currentScrollTop + containerHeight;
7643
7692
  const elHeight = elBottom - elTop;
7644
- if (elHeight <= containerHeight) {
7645
- if (elTop < currentScrollTop) {
7646
- newScrollTop = elTop;
7647
- } else if (elBottom > scrollBottom) {
7648
- newScrollTop = elBottom - containerHeight;
7693
+ if (elHeight <= viewHeight) {
7694
+ if (elTop < viewStart) {
7695
+ newScrollTop = scrollTopPuttingElAtViewStart;
7696
+ } else if (elBottom > viewEnd) {
7697
+ newScrollTop = scrollTopPuttingElAtViewEnd;
7649
7698
  }
7650
- } else if (elBottom < currentScrollTop) {
7651
- newScrollTop = elBottom - containerHeight;
7652
- } else if (elTop > scrollBottom) {
7653
- newScrollTop = elTop;
7699
+ } else if (elBottom < viewStart) {
7700
+ newScrollTop = scrollTopPuttingElAtViewEnd;
7701
+ } else if (elTop > viewEnd) {
7702
+ newScrollTop = scrollTopPuttingElAtViewStart;
7654
7703
  }
7655
7704
  }
7656
7705
 
7706
+ const scrollLeftPuttingElAtViewStart = elLeft - scrollPaddingLeft;
7707
+ const scrollLeftPuttingElAtViewEnd =
7708
+ elRight - containerWidth + scrollPaddingRight;
7709
+ const viewLeftEdge = currentScrollLeft + scrollPaddingLeft;
7710
+ const viewRightEdge = currentScrollLeft + containerWidth - scrollPaddingRight;
7711
+
7657
7712
  let newScrollLeft = currentScrollLeft;
7658
7713
  if (inline === "start") {
7659
- newScrollLeft = elLeft;
7714
+ newScrollLeft = scrollLeftPuttingElAtViewStart;
7660
7715
  } else if (inline === "end") {
7661
- newScrollLeft = elRight - containerWidth;
7716
+ newScrollLeft = scrollLeftPuttingElAtViewEnd;
7662
7717
  } else if (inline === "center") {
7663
- newScrollLeft = elLeft + (elRect.width - containerWidth) / 2;
7718
+ newScrollLeft = elLeft + (elRect.width - viewWidth) / 2 - scrollPaddingLeft;
7664
7719
  } else {
7665
7720
  // nearest: scroll only if partially or fully out of view.
7666
- // When the element is wider than the container, only scroll if it is
7721
+ // When the element is wider than the view, only scroll if it is
7667
7722
  // completely out of view — otherwise it is already as visible as possible.
7668
- const scrollRight = currentScrollLeft + containerWidth;
7669
7723
  const elWidth = elRight - elLeft;
7670
- if (elWidth <= containerWidth) {
7671
- if (elLeft < currentScrollLeft) {
7672
- newScrollLeft = elLeft;
7673
- } else if (elRight > scrollRight) {
7674
- newScrollLeft = elRight - containerWidth;
7724
+ if (elWidth <= viewWidth) {
7725
+ if (elLeft < viewLeftEdge) {
7726
+ newScrollLeft = scrollLeftPuttingElAtViewStart;
7727
+ } else if (elRight > viewRightEdge) {
7728
+ newScrollLeft = scrollLeftPuttingElAtViewEnd;
7675
7729
  }
7676
- } else if (elRight < currentScrollLeft) {
7677
- newScrollLeft = elRight - containerWidth;
7678
- } else if (elLeft > scrollRight) {
7679
- newScrollLeft = elLeft;
7730
+ } else if (elRight < viewLeftEdge) {
7731
+ newScrollLeft = scrollLeftPuttingElAtViewEnd;
7732
+ } else if (elLeft > viewRightEdge) {
7733
+ newScrollLeft = scrollLeftPuttingElAtViewStart;
7680
7734
  }
7681
7735
  }
7682
7736
 
@@ -7686,6 +7740,20 @@ const getScrollIntoViewScopedOffsets = (
7686
7740
  };
7687
7741
  };
7688
7742
 
7743
+ // "auto" is the one keyword scroll-padding takes, and it means "the browser
7744
+ // picks" — which is 0 everywhere in practice. Percentages resolve against the
7745
+ // scrollport, and getComputedStyle hands them back as written.
7746
+ const resolveScrollPadding = (value, scrollportSize) => {
7747
+ const number = parseFloat(value);
7748
+ if (Number.isNaN(number)) {
7749
+ return 0;
7750
+ }
7751
+ if (value.endsWith("%")) {
7752
+ return (number / 100) * scrollportSize;
7753
+ }
7754
+ return number;
7755
+ };
7756
+
7689
7757
  /**
7690
7758
  * DON'T USE THIS, use scroll-padding-top/bottom in CSS instead
7691
7759
  * better in every aspect
@@ -9978,6 +10046,13 @@ const createDragElementPositioner = (
9978
10046
  // returns null (document.documentElement instead — see its own doc).
9979
10047
  const positionedParent = getPositionedParent(elementToMove || element);
9980
10048
  const scrollContainer = getScrollContainer(element);
10049
+ // Whether what MOVES is pinned to the viewport — a copy carried above the page
10050
+ // in the top layer, a fixed panel dragged by its handle. Such a thing does not
10051
+ // travel with any scroll, so the scroll that happens while it is carried is
10052
+ // none of its business (see getScrollOffsets).
10053
+ const elementMovedIsPinnedToViewport = Boolean(
10054
+ findSelfOrAncestorFixedPosition(elementToMove || element),
10055
+ );
9981
10056
  const [getPositionOffsets, getScrollOffsets] = createGetOffsets({
9982
10057
  positionedParent,
9983
10058
  referencePositionedParent: referenceElement
@@ -9987,6 +10062,7 @@ const createDragElementPositioner = (
9987
10062
  referenceScrollContainer: referenceElement
9988
10063
  ? getScrollContainer(referenceElement)
9989
10064
  : scrollContainer,
10065
+ elementMovedIsPinnedToViewport,
9990
10066
  });
9991
10067
 
9992
10068
  {
@@ -10039,6 +10115,7 @@ const createGetOffsets = ({
10039
10115
  referencePositionedParent,
10040
10116
  scrollContainer,
10041
10117
  referenceScrollContainer,
10118
+ elementMovedIsPinnedToViewport,
10042
10119
  }) => {
10043
10120
  const samePositionedParent = positionedParent === referencePositionedParent;
10044
10121
  const getScrollOffsets = createGetScrollOffsets(
@@ -10046,6 +10123,7 @@ const createGetOffsets = ({
10046
10123
  referenceScrollContainer,
10047
10124
  positionedParent,
10048
10125
  samePositionedParent,
10126
+ elementMovedIsPinnedToViewport,
10049
10127
  );
10050
10128
 
10051
10129
  if (samePositionedParent) {
@@ -10250,6 +10328,7 @@ const createGetScrollOffsets = (
10250
10328
  referenceScrollContainer,
10251
10329
  positionedParent,
10252
10330
  samePositionedParent,
10331
+ elementMovedIsPinnedToViewport,
10253
10332
  ) => {
10254
10333
  const getGetScrollOffsetsSameContainer = () => {
10255
10334
  const scrollContainerIsDocument = scrollContainer === documentElement;
@@ -10271,9 +10350,14 @@ const createGetScrollOffsets = (
10271
10350
  return getScrollOffsetsFixed;
10272
10351
  }
10273
10352
  }
10353
+ // A thing pinned to the viewport is inside nothing that scrolls: the page
10354
+ // can run under it and it stays where it is. So it takes the frozen offset
10355
+ // below whatever containment says — and the document, as a scroll container,
10356
+ // answers "yes, it is inside me" to everything.
10274
10357
  const positionedParentIsInsideScrollContainer =
10275
- referenceScrollContainer === documentElement ||
10276
- referenceScrollContainer.contains(positionedParent);
10358
+ !elementMovedIsPinnedToViewport &&
10359
+ (referenceScrollContainer === documentElement ||
10360
+ referenceScrollContainer.contains(positionedParent));
10277
10361
  if (!positionedParentIsInsideScrollContainer) {
10278
10362
  // positionedParent is outside the scroll container (e.g. clone in document.body
10279
10363
  // while tracking an element inside a custom scroll container).
@@ -12066,6 +12150,7 @@ const css$1 = /* css */ `.navi_drop_hint {
12066
12150
  transform: scale(var(--drag-clone-scale, 1.03));
12067
12151
  transform-origin: var(--drag-origin);
12068
12152
  transition: transform .15s cubic-bezier(.34, 1.56, .64, 1), box-shadow .15s;
12153
+ translate: none !important;
12069
12154
  }
12070
12155
 
12071
12156
  @starting-style {
@@ -12096,90 +12181,6 @@ import.meta.css = [css$1, "@jsenv/dom/src/interaction/drag/drag_to.js"];
12096
12181
  const DRAG_IGNORED_SELECTOR =
12097
12182
  '[data-drag-ignore],[data-self-interactions~="drag"],[data-self-interactions~="*"],[popover],dialog';
12098
12183
 
12099
- /**
12100
- * Starts a drag-to-reorder interaction on a list item.
12101
- *
12102
- * Handles the full reorder UX:
12103
- * - Activates only once the intent is established — a short movement with a mouse, a long
12104
- * press with a finger (see `dragAfterIntent`), so that neither a click nor a scroll
12105
- * reorders anything by accident.
12106
- * - Clones the grabbed element and moves the clone while the original stays hidden in place
12107
- * (keeps the layout intact so other items don't shift during the drag).
12108
- * - The clone and the drop-hint live in the dragged element's own parent, so the CSS vars
12109
- * that dress them (`--drag-clone-shadow`, `--drop-hint-size`, …) reach them by plain
12110
- * inheritance, and so do the rules the list writes for its items.
12111
- * - Shows a drop-hint line indicating where the item will land.
12112
- * - Drop-target detection is intersection-based: the clone's bounding rect is compared
12113
- * against every item that matches `itemSelector` in the scroll container.
12114
- * - No-ops are filtered: releasing on the grabbed element itself, or in a position that
12115
- * would leave it at exactly the same index, never triggers `onReorder`.
12116
- * - On a valid drop, the clone animates to the drop position via the View Transitions API,
12117
- * `onReorder` is called inside the transition callback so the DOM update and the animation
12118
- * are captured together, then the clone is removed.
12119
- * - On a cancelled drop (pointer released with no valid target), the clone is removed
12120
- * immediately without calling `onReorder`.
12121
- *
12122
- * IDs are used as the bridge between DOM elements and JS state because:
12123
- * - Not all DOM elements matching `itemSelector` may be valid drop targets
12124
- * (holes in the structure), so DOM indices don't reliably map to state indices.
12125
- * - Virtual lists render fewer DOM nodes than the total item count, so
12126
- * DOM-index-based counting would be wrong.
12127
- *
12128
- * Any option not listed below is forwarded to `createDragToMoveGestureController`
12129
- * (`areaConstraint`, `autoScrollAreaPadding`, `stickyFrontiers`…), except
12130
- * `releasePositionEffect`, always `"manual"` here: what moves is the clone, and it
12131
- * is removed on release, so there is no position to commit or cancel.
12132
- *
12133
- * @param {PointerEvent} event
12134
- * The `pointerdown` event that may become a reorder.
12135
- * @param {object} options
12136
- * @param {Element} [options.draggedElement=event.currentTarget]
12137
- * The list item to drag.
12138
- * @param {Element} [options.containerElement=draggedElement.parentElement]
12139
- * Element searched with `itemSelector` to find the items to drop between.
12140
- * @param {string} [options.itemSelector]
12141
- * CSS selector that matches all list items inside `containerElement`.
12142
- * Used for drop-target detection and no-op filtering. Left out, nothing is a
12143
- * drop target: no hint is drawn and no reorder can be answered — which is what
12144
- * a drag that only ever throws the thing away asks for.
12145
- * @param {function} options.getItemId
12146
- * Returns the stable ID for a given DOM element.
12147
- * Signature: `getItemId(element) → id`.
12148
- * @param {function} options.onReorder
12149
- * Called when the user drops the item in a new position.
12150
- * Signature: `onReorder(fromId, toId, syncCloneWithDropTarget)`.
12151
- * - `fromId`: stable ID of the dragged item.
12152
- * - `toId`: stable ID of the item to insert before, or `null` to append at the end.
12153
- * - `syncCloneWithDropTarget`: call it synchronously inside a
12154
- * `document.startViewTransition` callback, next to the DOM mutation, so the
12155
- * clone is captured at its landing position.
12156
- * @param {(detail: {gestureInfo: object, dropTarget: Element|null}) => "reorder"|"toss"|"cancel"} [options.resolveDrop]
12157
- * What THIS release means, when the answer is not simply "a target was found or
12158
- * not": the same grab can be meant to reorder or to get rid of the thing, and
12159
- * only the caller knows which — far and fast is a throw, over a row is a move.
12160
- * Left out, a drop target reorders and anything else is cancelled.
12161
- * @param {(detail: {gestureInfo: object}) => Promise|void} [options.onToss]
12162
- * The release was a throw. The clone leaves the screen the way it was thrown
12163
- * while this runs; it comes back if the promise rejects, because the thing still
12164
- * exists and the screen has to say so.
12165
- * @param {object} [options.direction={ x: false, y: true }]
12166
- * Axes along which dragging is allowed. Passed to `createDragToMoveGestureController`.
12167
- * @param {number} [options.threshold=5]
12168
- * Distance (px) a mouse must travel before the press becomes a drag.
12169
- * @param {boolean|"if-touch"} [options.longPress="if-touch"]
12170
- * Which pointers start the drag by holding still instead of by travelling.
12171
- * @param {number} [options.longPressDelay=400]
12172
- * How long (ms) such a pointer must stay down.
12173
- * @param {number} [options.longPressSlop=8]
12174
- * How far (px) it may drift during that wait before the press is abandoned.
12175
- * @param {function} [options.onPressStart]
12176
- * The pointer went down and the wait began (a cue that the press counts).
12177
- * @param {function} [options.onPressCancel]
12178
- * The pointer moved or lifted before the wait was over.
12179
- * @param {function} [options.onPress]
12180
- * The wait completed and the item is now held (haptics, scale…).
12181
- */
12182
-
12183
12184
  /**
12184
12185
  * Creates a gesture controller that moves elements via drag.
12185
12186
  *
@@ -12588,25 +12589,43 @@ const createDragToMoveGestureController = ({
12588
12589
  * It was put somewhere. The position is already committed when this runs — the
12589
12590
  * hand let go of it there — and travels back if the promise rejects.
12590
12591
  * @param {Element} [options.containerElement=draggedElement.parentElement]
12591
- * Searched with `itemSelector` for the items to drop between.
12592
+ * Where the places are looked for with `itemSelector`, and where the drop hint
12593
+ * is drawn. The parent covers a list and a board, whose items are siblings of
12594
+ * what is carried; anything else has to be said — a palette beside the surface
12595
+ * it fills, a piece drawn INSIDE the place it can be put back on.
12592
12596
  * @param {string} [options.itemSelector] What matches the items of the list.
12593
- * @param {function} [options.getItemId] `getItemId(element) → id`.
12597
+ * Left out, nothing is a place: no hint is drawn and no landing can be
12598
+ * answered, which is what a drag that only ever throws the thing away asks for.
12599
+ * @param {function} [options.getItemId] `getItemId(element) → id`. An id rather
12600
+ * than a DOM index, because the two do not match: a list draws fewer rows than
12601
+ * it has, a search reorders them, and the structure may have holes.
12594
12602
  * @param {function} [options.onReorder]
12595
12603
  * `onReorder(fromId, toId, syncCloneWithDropTarget)` — see its own note below.
12596
12604
  * @param {(detail: {gestureInfo: object}) => Promise|void} [options.onToss]
12597
12605
  * It was thrown away. The copy leaves the screen while this runs and comes back
12598
12606
  * if the promise rejects, because the thing still exists and the screen has to
12599
12607
  * say so.
12600
- * @param {function} [options.onLand]
12601
- * `onLand(fromId, toId, syncCloneWithDropTarget)` — it came down on `toId`, which
12602
- * is an element and never null: nothing under the copy is a cancelled release.
12603
- * The copy is held until what comes back settles, exactly like `onReorder`.
12604
- * `syncCloneWithDropTarget` takes an element when the place is not the shape of
12605
- * what stands on it: the copy then takes THAT box instead of the target's.
12608
+ * @param {(detail: {fromId: string, toId: string, x: number, y: number, width: number, height: number, syncCloneWithDropTarget: Function}) => Promise|void} [options.onLand]
12609
+ * It came down on `toId`, which is an element and never null: nothing under the
12610
+ * copy is a cancelled release. `x`/`y`/`width`/`height` say WHERE on it, which
12611
+ * is the whole answer when the place is a surface — a plan, a map — with no
12612
+ * element under the copy to name. The copy is held until what comes back
12613
+ * settles, exactly like `onReorder`. `syncCloneWithDropTarget` takes an element
12614
+ * when the place is not the shape of what stands on it: the copy then takes
12615
+ * THAT box instead of the target's.
12606
12616
  * @param {number} [options.tossDistance=110] How far a throw goes, in px.
12607
12617
  * @param {number} [options.tossSpeed=0.45] And how fast, in px/ms. BOTH are asked
12608
12618
  * for: one without the other is moving the thing while hesitating, and nothing is
12609
12619
  * thrown away on a hesitation.
12620
+ * @param {("throw"|"release-outside")[]} [options.tossBy=["throw"]] How a toss
12621
+ * is made here. A THROW is far and fast — the flick that gets rid of something,
12622
+ * a list's gesture, judged before any landing. A RELEASE OUTSIDE has no place
12623
+ * under it — dragging something off the surface it belongs to and letting go,
12624
+ * deliberate and never a flick, a surface's gesture. Each is asked for on its
12625
+ * own: a surface wants only the second (a fast drag across it that ends ON it
12626
+ * has not asked for the thing to go), a list only the first (a row let go of
12627
+ * beside its list is a row put back). With no place to be outside of (no
12628
+ * `itemSelector`), every release is outside.
12610
12629
  *
12611
12630
  * Everything else is forwarded to `createDragToMoveGestureController`
12612
12631
  * (`areaConstraint`, `autoScrollAreaPadding`, `direction`…) and to `dragAfterIntent`
@@ -12721,6 +12740,8 @@ const startDragToMoveElement = (
12721
12740
  // hesitating, and nothing is thrown away on a hesitation — it comes back.
12722
12741
  const TOSS_DISTANCE_TO_COMMIT = 110;
12723
12742
  const TOSS_SPEED_TO_COMMIT = 0.45;
12743
+ // The flick is how a list gets rid of a row; a surface says otherwise.
12744
+ const TOSS_BY_DEFAULT = ["throw"];
12724
12745
 
12725
12746
  const resolveDropMeaning = ({
12726
12747
  gestureInfo,
@@ -12730,6 +12751,7 @@ const resolveDropMeaning = ({
12730
12751
  canLand,
12731
12752
  tossDistance = TOSS_DISTANCE_TO_COMMIT,
12732
12753
  tossSpeed = TOSS_SPEED_TO_COMMIT,
12754
+ tossBy = TOSS_BY_DEFAULT,
12733
12755
  }) => {
12734
12756
  if (gestureInfo.cancelled) {
12735
12757
  // Nobody let go of anything: the gesture was taken away mid-air (the
@@ -12737,7 +12759,7 @@ const resolveDropMeaning = ({
12737
12759
  // to be at that moment is not a place it was put.
12738
12760
  return "cancel";
12739
12761
  }
12740
- if (canToss) {
12762
+ if (canToss && tossBy.includes("throw")) {
12741
12763
  const { xDelta, yDelta } = gestureInfo.layout;
12742
12764
  const distance = Math.hypot(xDelta, yDelta);
12743
12765
  if (distance > tossDistance && gestureInfo.velocity > tossSpeed) {
@@ -12752,6 +12774,21 @@ const resolveDropMeaning = ({
12752
12774
  return "reorder";
12753
12775
  }
12754
12776
  }
12777
+ if (canToss && tossBy.includes("release-outside")) {
12778
+ // Let go of away from every place: the other way of meaning "get rid of
12779
+ // this", and the one a surface asks for — off the plan and down is
12780
+ // deliberate, never a flick.
12781
+ //
12782
+ // "Away from every place" is the last frame's answer, not the one above: a
12783
+ // row let go of where it already was has no place to INSERT it at and still
12784
+ // has a place under it. And it has to have gone somewhere to be away from
12785
+ // anything — picked up and put straight back down is a hand that changed its
12786
+ // mind, not a thing dropped over nothing.
12787
+ const { xDelta, yDelta } = gestureInfo.layout;
12788
+ if ((xDelta || yDelta) && !gestureInfo.dropTargetInfo) {
12789
+ return "toss";
12790
+ }
12791
+ }
12755
12792
  return "cancel";
12756
12793
  };
12757
12794
 
@@ -12788,6 +12825,7 @@ const startDragToCarryCopy = (
12788
12825
  onToss,
12789
12826
  tossDistance,
12790
12827
  tossSpeed,
12828
+ tossBy,
12791
12829
  // A list runs one way and reordering walks it; a board has places all around,
12792
12830
  // so something landing on one of them goes wherever the hand takes it.
12793
12831
  direction = canLand ? { x: true, y: true } : { x: false, y: true },
@@ -12853,10 +12891,12 @@ const startDragToCarryCopy = (
12853
12891
  ? createDropHint()
12854
12892
  : null;
12855
12893
  if (dropHintEl) {
12856
- // In the container it draws into, which is where its own vars are set: the
12894
+ // Among the places it lights up, which is where its own vars are set: the
12857
12895
  // shape of a drop hint is a property of the list or board it belongs to,
12858
- // and reading it from there is inheritance rather than a hand-off.
12859
- draggedElement.parentElement.appendChild(dropHintEl);
12896
+ // and reading it from there is inheritance rather than a hand-off. The
12897
+ // copy goes the other way (see createDragClone) — it is dressed by where
12898
+ // the thing it copies stands, which may be a palette far from here.
12899
+ containerElement.appendChild(dropHintEl);
12860
12900
  }
12861
12901
  // The hint first, the clone second: that order is what stacks them in the
12862
12902
  // top layer. A copy taken back in hand is already up there, and the hint
@@ -13027,6 +13067,7 @@ const startDragToCarryCopy = (
13027
13067
  canLand,
13028
13068
  tossDistance,
13029
13069
  tossSpeed,
13070
+ tossBy,
13030
13071
  });
13031
13072
 
13032
13073
  // Let go of and still on the screen: from here until it is taken away
@@ -13075,12 +13116,17 @@ const startDragToCarryCopy = (
13075
13116
  await settleCloneBack(cloneWrapper, draggedElement);
13076
13117
  }
13077
13118
  } else if (dropMeans === "land") {
13119
+ // Read before the copy is put down: landing bakes its position and
13120
+ // drops the transform, and where it came down is where the hand let
13121
+ // go of it.
13122
+ const landedAt = getRectInside(cloneWrapper, currentReleaseElement);
13078
13123
  await landCopyOn(currentReleaseElement, (syncCloneWithDropTarget) =>
13079
- onLand(
13080
- getItemId(draggedElement),
13081
- getItemId(currentReleaseElement),
13124
+ onLand({
13125
+ fromId: getItemId(draggedElement),
13126
+ toId: getItemId(currentReleaseElement),
13127
+ ...landedAt,
13082
13128
  syncCloneWithDropTarget,
13083
- ),
13129
+ }),
13084
13130
  );
13085
13131
  } else if (dropMeans === "reorder") {
13086
13132
  await landCopyOn(currentReleaseElement, (syncCloneWithDropTarget) =>
@@ -13135,6 +13181,28 @@ const setCloneViewportRect = (cloneWrapper, el) => {
13135
13181
  cloneWrapper.style.setProperty("--clone-height", `${rect.height}px`);
13136
13182
  };
13137
13183
 
13184
+ // Where one box sits inside another, in that other's own content: its border and
13185
+ // its scroll are taken out, so the numbers say where IN the thing rather than
13186
+ // where in what is visible of it at that moment.
13187
+ const getRectInside = (element, containerElement) => {
13188
+ const rect = element.getBoundingClientRect();
13189
+ const containerRect = containerElement.getBoundingClientRect();
13190
+ return {
13191
+ x:
13192
+ rect.left -
13193
+ containerRect.left -
13194
+ containerElement.clientLeft +
13195
+ containerElement.scrollLeft,
13196
+ y:
13197
+ rect.top -
13198
+ containerRect.top -
13199
+ containerElement.clientTop +
13200
+ containerElement.scrollTop,
13201
+ width: rect.width,
13202
+ height: rect.height,
13203
+ };
13204
+ };
13205
+
13138
13206
  // Creates the two-layer clone structure used for drag-to-reorder.
13139
13207
  //
13140
13208
  // Layer 1 — wrapper (navi-drag-clone-wrapper):
@@ -13383,6 +13451,17 @@ const createDragClone = (element, pointerEvent) => {
13383
13451
  // (The original wears it too, but it is hidden — see navi-drag-clone-source.)
13384
13452
  elementClone.setAttribute("data-grabbed", "");
13385
13453
  elementClone.style.viewTransitionName = "navi-drag-clone";
13454
+ // The copy takes the wrapper's box, and nothing the page said about where the
13455
+ // ORIGINAL stands may place it: a piece drawn at "left: 40px; top: 130px" on
13456
+ // its board, a marker centred by a translate, a card pushed by a margin — the
13457
+ // deep copy carries all of that, inline styles included, and would sit that far
13458
+ // from the wrapper's corner, so from the hand. Written inline on the copy
13459
+ // because that is the one place that outranks both the inline values it copied
13460
+ // and any rule of the page's, without a stylesheet having to shout !important.
13461
+ elementClone.style.position = "absolute";
13462
+ elementClone.style.inset = "0";
13463
+ elementClone.style.margin = "0";
13464
+ elementClone.style.translate = "none";
13386
13465
 
13387
13466
  wrapper.appendChild(elementClone);
13388
13467
  // Beside the thing it copies, so it stands where that thing stands: every
@@ -14672,6 +14751,12 @@ const isAncestorOpen = (ancestor) => {
14672
14751
  * own closed-state CSS ([navi-hidden], :not([popover])) for the custom
14673
14752
  * renderers — so nothing inside one answers true here, while everything a
14674
14753
  * trigger keeps on screen does.
14754
+ *
14755
+ * Ask it about an ancestor that is closed AND settled — on mount, or when
14756
+ * setting up a long-lived observer. A surface animating its way out still has a
14757
+ * box for the length of the animation (transition-behavior: allow-discrete on
14758
+ * display/overlay), so asked at the instant one closes this says "on screen"
14759
+ * about something on its way off it.
14675
14760
  */
14676
14761
  const isDisplayedDespiteClosedAncestor = (element) => {
14677
14762
  if (typeof element.checkVisibility !== "function") {
@@ -15332,6 +15417,20 @@ window.addEventListener("resize", (event) => {
15332
15417
  const getVisibleViewportRect = () => {
15333
15418
  const visualViewport = window.visualViewport;
15334
15419
  const documentElement = document.documentElement;
15420
+ // Read FIRST, before the visual viewport just below — and used again in the
15421
+ // branch further down, so it is a read this function needs either way.
15422
+ //
15423
+ // The visual viewport's own numbers are refreshed with the frame's geometry,
15424
+ // and reading the layout viewport is what brings that geometry up to date.
15425
+ // Read them the other way round and the answer is the state as it was before
15426
+ // the caller's own measuring — every caller here measures an element moments
15427
+ // later, with getBoundingClientRect/offsetHeight, which forces exactly that
15428
+ // update. The box would then be measured in one layout state and placed in
15429
+ // another; on a phone opening its keyboard the two are a screen apart, the
15430
+ // popup lands where the pre-keyboard viewport put it, and nothing after
15431
+ // corrects it. Costs no extra layout: it moves the one the caller triggers
15432
+ // anyway rather than adding a second.
15433
+ const layoutViewportHeight = window.innerHeight;
15335
15434
  if (!visualViewport) {
15336
15435
  return {
15337
15436
  left: 0,
@@ -15358,8 +15457,8 @@ const getVisibleViewportRect = () => {
15358
15457
  ) {
15359
15458
  offsetLeft = 0;
15360
15459
  offsetTop = 0;
15361
- if (window.innerHeight > height) {
15362
- height = window.innerHeight;
15460
+ if (layoutViewportHeight > height) {
15461
+ height = layoutViewportHeight;
15363
15462
  }
15364
15463
  }
15365
15464
  return {
@@ -15971,7 +16070,23 @@ const visibleRectEffect = (
15971
16070
  let currentOpenableAncestor = selfOrClosestOpenableAncestor(element);
15972
16071
  while (currentOpenableAncestor) {
15973
16072
  const openableAncestor = currentOpenableAncestor;
15974
- if (!isAncestorOpen(openableAncestor)) {
16073
+ const openableAncestorIsOpen = isAncestorOpen(openableAncestor);
16074
+ // A closed openable that keeps `element` on screen is the *trigger* of
16075
+ // what is closed rather than the thing itself — a picker's root, an
16076
+ // expandable's header, a <summary> all carry aria-expanded/open for a
16077
+ // surface they are not part of, and `element` here belongs to the
16078
+ // façade they keep showing the whole time. Its closed state hides
16079
+ // nothing of this element, so it must not count as a state that does.
16080
+ //
16081
+ // Read once, here, and kept for the effect's lifetime: the layout only
16082
+ // answers this question honestly about an ancestor that is already
16083
+ // closed and settled. Asked at the instant a surface closes, it still
16084
+ // says "on screen" for as long as a close animation keeps a box for it,
16085
+ // which is the opposite answer — so a surface closing later goes on
16086
+ // counting, unasked.
16087
+ const elementIsInAncestorFacade =
16088
+ !openableAncestorIsOpen && isDisplayedDespiteClosedAncestor(element);
16089
+ if (!openableAncestorIsOpen && !elementIsInAncestorFacade) {
15975
16090
  ancestorClosedCount++;
15976
16091
  pauseResizeWatching();
15977
16092
  }
@@ -15980,6 +16095,9 @@ const visibleRectEffect = (
15980
16095
  // eslint-disable-next-line no-loop-func
15981
16096
  ({ isOpen, toggleEvent }) => {
15982
16097
  if (!isOpen) {
16098
+ if (elementIsInAncestorFacade) {
16099
+ return;
16100
+ }
15983
16101
  ancestorClosedCount++;
15984
16102
  pauseResizeWatching();
15985
16103
  // Invalidates check()'s own "did anything actually change"
@@ -16008,7 +16126,7 @@ const visibleRectEffect = (
16008
16126
  );
16009
16127
  return;
16010
16128
  }
16011
- if (ancestorClosedCount > 0) {
16129
+ if (!elementIsInAncestorFacade && ancestorClosedCount > 0) {
16012
16130
  ancestorClosedCount--;
16013
16131
  }
16014
16132
  if (ancestorClosedCount === 0) {
@@ -17030,21 +17148,17 @@ const parseTransitionDurationMs = (element, cssVarName, fallbackMs) => {
17030
17148
  * A second reposition landing mid-animation cancels the pending one and
17031
17149
  * flushes its own registered callbacks immediately (same spirit as a real
17032
17150
  * `transitioncancel`), so nothing is left waiting on a superseded `onEnd`.
17151
+ * Every second reposition, animated or not — see
17152
+ * cancelPendingPositionTransition, which applyNewPosition calls on the path
17153
+ * that does not come through here.
17033
17154
  *
17034
- * `commitStyles()` below isn't what makes the final position correct —
17035
- * `applyNewPosition` already sets the specified `left`/`top` before this
17036
- * animation starts, so it takes back over once the active duration elapses
17037
- * regardless. It just makes that explicit instead of relying on `fill:
17038
- * "none"` timing, and drops the finished Animation instead of leaving it.
17155
+ * `commitStyles()` below is what the specified `translate` reads once the
17156
+ * active duration elapses, so it must only ever run while this animation is
17157
+ * still the current placement: it writes THIS animation's target, which a
17158
+ * placement landing after it has already superseded.
17039
17159
  */
17040
17160
  const notifyPositionTransition = (element, animation) => {
17041
- const pending = pendingPositionTransitions.get(element);
17042
- if (pending) {
17043
- pending.animation.cancel();
17044
- for (const callback of pending.endCallbacks) {
17045
- callback();
17046
- }
17047
- }
17161
+ cancelPendingPositionTransition(element);
17048
17162
  const endCallbacks = [];
17049
17163
  dispatchCustomEvent(element, "navi_position_transition", {
17050
17164
  onEnd: (callback) => {
@@ -17074,6 +17188,31 @@ const notifyPositionTransition = (element, animation) => {
17074
17188
  });
17075
17189
  };
17076
17190
 
17191
+ /**
17192
+ * Ends the placement animation in flight on `element`, if any, and flushes what
17193
+ * was waiting on it — what a new placement owes the old one, whether or not it
17194
+ * animates itself.
17195
+ *
17196
+ * The non-animated case is the one worth naming: a placement that just writes
17197
+ * `translate` leaves an animation started a moment earlier running, and that
17198
+ * animation commits ITS target when it ends, on top of the value written since.
17199
+ * The box then lands where a viewport that no longer exists put it, and nothing
17200
+ * comes after to correct it — a phone opening its keyboard fires exactly this
17201
+ * sequence, an animated "resize" followed by focus/size deliveries that do not
17202
+ * animate.
17203
+ */
17204
+ const cancelPendingPositionTransition = (element) => {
17205
+ const pending = pendingPositionTransitions.get(element);
17206
+ if (!pending) {
17207
+ return;
17208
+ }
17209
+ pendingPositionTransitions.delete(element);
17210
+ pending.animation.cancel();
17211
+ for (const callback of pending.endCallbacks) {
17212
+ callback();
17213
+ }
17214
+ };
17215
+
17077
17216
  /**
17078
17217
  * Applies a `pickPositionRelativeTo` result to `element`, as a `translate`.
17079
17218
  *
@@ -17196,6 +17335,8 @@ const applyNewPosition = (
17196
17335
  },
17197
17336
  );
17198
17337
  notifyPositionTransition(element, animation);
17338
+ } else {
17339
+ cancelPendingPositionTransition(element);
17199
17340
  }
17200
17341
  // The specified translate is set to its final target right away,
17201
17342
  // regardless of `shouldTransition` — the animation above only plays the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.17.45",
3
+ "version": "0.17.47",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {
@@ -35,12 +35,16 @@
35
35
  "build": "node ./scripts/build.mjs",
36
36
  "prepublishOnly": "npm run build"
37
37
  },
38
+ "peerDependencies": {
39
+ "@preact/signals": "^2.11.2",
40
+ "preact": "^11.0.0-beta.2"
41
+ },
38
42
  "dependencies": {},
39
43
  "devDependencies": {
40
44
  "@jsenv/core": "../../../",
41
45
  "@jsenv/navi": "../navi",
42
46
  "@jsenv/snapshot": "../../tooling/snapshot",
43
- "@preact/signals": "2.11.1",
47
+ "@preact/signals": "2.11.2",
44
48
  "preact": "11.0.0-beta.2"
45
49
  },
46
50
  "publishConfig": {