@jsenv/dom 0.17.5 → 0.17.7

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 +2491 -1243
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -7628,1316 +7628,1724 @@ const applyWheelScrollThrough = (element, wheelEvent) => {
7628
7628
  });
7629
7629
  };
7630
7630
 
7631
- /**
7632
- * The element `element` is genuinely `position: absolute`/`fixed` relative
7633
- * to: its own nearest positioned ancestor (walking up the DOM tree), or
7634
- * `document.documentElement` (the viewport) if none is found.
7635
- *
7636
- * Also aware of `element` itself being promoted to the top layer: a
7637
- * `<dialog>` actually shown modally (`showModal()`, matches `:modal` — a
7638
- * `.show()`'d, non-modal dialog does NOT match and is positioned like any
7639
- * other in-flow element instead, walked up normally below), or *any*
7640
- * `[popover]` element, always uses the initial containing block (the
7641
- * viewport) regardless of its own `position` or DOM ancestry — walking up
7642
- * its own parent chain (what the rest of this function does) would give
7643
- * the wrong answer for these two specifically, since their real DOM
7644
- * position becomes irrelevant to their own containing block the moment
7645
- * they're actually promoted. Checked via the `popover` attribute itself,
7646
- * not the live `:popover-open` state — unlike `<dialog>`, a `[popover]`
7647
- * element has no "local" mode: it's always top-layer-bound once shown,
7648
- * regardless of whether it happens to be open right this moment, so the
7649
- * static attribute alone is enough (and correct even when called just
7650
- * before `showPopover()` actually runs, when `:popover-open` isn't true
7651
- * yet).
7652
- *
7653
- * `document.documentElement` (not `document.body`, not `null`) is this
7654
- * function's own "no real container — use the viewport" sentinel:
7655
- * `documentElement` is the actual initial containing block, so the walk
7656
- * below stops there without testing its own `position` (there's nothing
7657
- * beyond it to fall back to anyway) — unlike the previous version of this
7658
- * function, which stopped one level too early, at `document.body`, without
7659
- * ever testing *its* `position` either (a `position: relative` body, for
7660
- * instance, would have been silently skipped). Returning `documentElement`
7661
- * instead of `null` also means no special-casing is needed by callers that
7662
- * already compare a resolved container against `document.documentElement`
7663
- * (see e.g. visible_rect.js's own `hasRealContainer` check).
7664
- */
7665
- const getPositionedParent = (element) => {
7666
- const isPromotedToTopLayer =
7667
- (element.tagName === "DIALOG" && element.matches(":modal")) ||
7668
- element.hasAttribute("popover");
7669
- if (isPromotedToTopLayer) {
7670
- return document.documentElement;
7671
- }
7672
- let parent = element.parentElement;
7673
- while (parent && parent !== document.documentElement) {
7674
- const position = window.getComputedStyle(parent).position;
7675
- if (
7676
- position === "relative" ||
7677
- position === "absolute" ||
7678
- position === "fixed"
7679
- ) {
7680
- return parent;
7681
- }
7682
- parent = parent.parentElement;
7683
- }
7684
- return document.documentElement;
7685
- };
7631
+ const installImportMetaCssBuild = (importMeta) => {
7632
+ const IMPORT_META_CSS_BUILD = "jsenv_import_meta_css_build";
7686
7633
 
7687
- /**
7688
- * Walks `element` and its ancestors (stopping at, but not including,
7689
- * `document.documentElement`) looking for the first one whose *computed*
7690
- * `position` is `fixed` — i.e. pinned to the viewport, ignoring document
7691
- * scroll, regardless of what `element` itself is positioned relative to.
7692
- *
7693
- * @param {Element} element
7694
- * @returns {[left: number, top: number] | null} The fixed ancestor's own
7695
- * viewport-relative `getBoundingClientRect()` origin, or `null` if neither
7696
- * `element` nor any ancestor is fixed (i.e. `element` genuinely scrolls
7697
- * with the document).
7698
- */
7699
- const findSelfOrAncestorFixedPosition = (element) => {
7700
- let current = element;
7701
- while (true) {
7702
- const computedStyle = window.getComputedStyle(current);
7703
- if (computedStyle.position === "fixed") {
7704
- const { left, top } = current.getBoundingClientRect();
7705
- return [left, top];
7706
- }
7707
- current = current.parentElement;
7708
- if (!current || current === document.documentElement) {
7709
- break;
7710
- }
7634
+ if (importMeta.css === IMPORT_META_CSS_BUILD) {
7635
+ return;
7711
7636
  }
7712
- return null;
7637
+
7638
+ const stylesheetMap = new Map();
7639
+ const adopt = (url, value) => {
7640
+ const stylesheet = new CSSStyleSheet({ baseUrl: importMeta.url });
7641
+ stylesheet.replaceSync(value);
7642
+ stylesheetMap.set(url, stylesheet);
7643
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet];
7644
+ };
7645
+ const update = (url, value) => {
7646
+ stylesheetMap.get(url).replaceSync(value);
7647
+ };
7648
+ const remove = (url) => {
7649
+ const stylesheet = stylesheetMap.get(url);
7650
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
7651
+ (s) => s !== stylesheet,
7652
+ );
7653
+ stylesheetMap.delete(url);
7654
+ };
7655
+
7656
+ const currentCssSourceMap = new Map();
7657
+ Object.defineProperty(importMeta, "css", {
7658
+ configurable: true,
7659
+ get() {
7660
+ return IMPORT_META_CSS_BUILD;
7661
+ },
7662
+ set([value, url]) {
7663
+ if (value === undefined) {
7664
+ if (stylesheetMap.has(url)) {
7665
+ remove(url);
7666
+ currentCssSourceMap.delete(url);
7667
+ }
7668
+ return;
7669
+ }
7670
+ if (!stylesheetMap.has(url)) {
7671
+ adopt(url, value);
7672
+ currentCssSourceMap.set(url, value);
7673
+ } else if (currentCssSourceMap.get(url) !== value) {
7674
+ update(url, value);
7675
+ currentCssSourceMap.set(url, value);
7676
+ }
7677
+ },
7678
+ });
7713
7679
  };
7714
7680
 
7715
7681
  /**
7716
- * Creates a coordinate system positioner for drag operations.
7717
- *
7718
- * PURPOSE:
7719
- * During a drag gesture, the system tracks mouse movement as "scrollable coordinates"
7720
- * relative to the scroll container. This function converts those coordinates into
7721
- * the actual CSS transform values needed to visually move an element (or a separate
7722
- * elementToMove) to follow the mouse.
7723
- *
7724
- * PARAMETERS:
7725
- * - element: The element being grabbed / tracked for drag detection and auto-scroll.
7726
- * - referenceElement: Optional. The element whose coordinate system defines the input space.
7727
- * When provided, scrollable coords are relative to its scroll container.
7728
- * Defaults to element itself.
7729
- * - elementToMove: Optional. A different element to apply the transform to (e.g. a clone
7730
- * or a table that moves as a whole when a column is dragged).
7731
- * When provided, its offsetParent is used as the positioning context.
7732
- *
7733
- * THE COORDINATE PIPELINE:
7734
- *
7735
- * Mouse position
7736
- * → scrollable coords (relative to referenceScrollContainer, scroll-independent)
7737
- * → positioned coords (relative to elementToMove's offsetParent, for CSS transform)
7738
- *
7739
- * Two types of offsets bridge these spaces:
7682
+ * Isolates user interactions to only the specified elements, making everything else non-interactive.
7740
7683
  *
7741
- * 1. POSITION OFFSETS (getPositionOffsets):
7742
- * Compensate for the fact that positionedParent and referencePositionedParent
7743
- * may differ. For example, if `element` lives inside a <table> and `elementToMove`
7744
- * is a full table clone, their offsetParents are different elements.
7745
- * This offset is the spatial difference between those two positioned ancestors.
7746
- * Called dynamically because parents can move (e.g. overlay elements).
7684
+ * This creates a controlled interaction environment where only the target elements (and their ancestors)
7685
+ * can receive user input like clicks, keyboard events, focus, etc. All other DOM elements become
7686
+ * non-interactive, preventing conflicting or unwanted interactions during critical operations
7687
+ * like drag gestures, modal dialogs, or complex UI states.
7747
7688
  *
7748
- * 2. SCROLL OFFSETS (getScrollOffsets):
7749
- * Account for the scroll position of the relevant scroll container(s).
7750
- * The math ensures that at grab time, the transform delta is zero (element
7751
- * stays at its visual position), and subsequent mouse movement maps 1:1
7752
- * to transform change.
7689
+ * The function uses the `inert` attribute to achieve this isolation, applying it strategically
7690
+ * to parts of the DOM tree while preserving the interactive elements and their ancestor chains.
7753
7691
  *
7754
- * CRITICAL CASE positionedParent outside referenceScrollContainer:
7755
- * When elementToMove's offsetParent is NOT inside the referenceScrollContainer
7756
- * (e.g. a clone appended to document.body while tracking an element inside
7757
- * an overflow:auto div), the scroll offset must be FROZEN at grab time.
7758
- * Using a live scroll value would double-move the clone during auto-scroll:
7759
- * the scrollable coordinate decreases (element appears to move up) AND the
7760
- * live scroll value increases — both applied to the same transform.
7761
- * Freezing the scroll at grab time cancels this out while still correctly
7762
- * placing the clone at the right initial position.
7692
+ * Example DOM structure and inert application:
7763
7693
  *
7764
- * KEY SCENARIOS SUPPORTED:
7765
- * 1. Same positioned parent, same scroll container — minimal offsets
7766
- * 2. Different positioned parents, same scroll container — position offset compensation
7767
- * 3. Same positioned parent, different scroll containers — scroll offset bridging
7768
- * 4. Different positioned parents, different containers — full offset compensation
7769
- * 5. Overlay elements (data-overlay-for) — specialized offset path
7770
- * 6. Fixed positioned elements — special scroll handling
7771
- * 7. elementToMove outside referenceScrollContainer frozen scroll offset at grab
7694
+ * Before calling isolateInteractions:
7695
+ * ```
7696
+ * <body>
7697
+ * <header>...</header>
7698
+ * <main>
7699
+ * <div>
7700
+ * <span>some content</span>
7701
+ * <div class="modal">modal content</div>
7702
+ * <span>more content</span>
7703
+ * </div>
7704
+ * <aside inert>already inert</aside>
7705
+ * <div class="dropdown">dropdown menu</div>
7706
+ * </main>
7707
+ * <footer>...</footer>
7708
+ * </body>
7709
+ * ```
7772
7710
  *
7773
- * API CONTRACT:
7774
- * Returns [scrollableLeft, scrollableTop, convertScrollablePosition] where:
7711
+ * After calling isolateInteractions([modal, dropdown]):
7712
+ * ```
7713
+ * <body>
7714
+ * <header inert>...</header> ← made inert (no active descendants)
7715
+ * <main> ← not inert because it contains active elements
7716
+ * <div> ← not inert because it contains .modal
7717
+ * <span inert>some content</span> ← made inert selectively
7718
+ * <div class="modal">modal content</div> ← stays active
7719
+ * <span inert>more content</span> ← made inert selectively
7720
+ * </div>
7721
+ * <aside inert>already inert</aside>
7722
+ * <div class="dropdown">dropdown menu</div> ← stays active
7723
+ * </main>
7724
+ * <footer inert>...</footer>
7725
+ * </body>
7726
+ * ```
7775
7727
  *
7776
- * - scrollableLeft/scrollableTop:
7777
- * The element's current position in the reference coordinate system at grab time.
7778
- * Used as the layout starting point (layoutScrollableLeft/Top) by the gesture system.
7728
+ * After calling cleanup():
7729
+ * ```
7730
+ * <body>
7731
+ * <header>...</header>
7732
+ * <main>
7733
+ * <div>
7734
+ * <span>some content</span>
7735
+ * <div class="modal">modal content</div>
7736
+ * <span>more content</span>
7737
+ * </div>
7738
+ * <aside inert>already inert</aside> ← [inert] preserved
7739
+ * <div class="dropdown">dropdown menu</div>
7740
+ * </main>
7741
+ * <footer>...</footer>
7742
+ * </body>
7743
+ * ```
7779
7744
  *
7780
- * - convertScrollablePosition(scrollableLeft, scrollableTop):
7781
- * Converts a scrollable coordinate (from the gesture layout) into a positioned
7782
- * coordinate suitable for CSS transform. The gesture system computes:
7783
- * topDelta = convertScrollablePosition(layout.scrollableTop) - topAtGrab
7784
- * and applies that as translateY. At grab time, delta = 0. As the mouse moves,
7785
- * delta tracks the movement exactly, regardless of scroll context differences.
7745
+ * @param {Array<Element>} elements - Array of elements to keep interactive (non-inert)
7746
+ * @returns {Function} cleanup - Function to restore original inert states
7786
7747
  */
7787
- const createDragElementPositioner = (
7788
- element,
7789
- referenceElement,
7790
- elementToMove,
7791
- ) => {
7792
- let scrollableLeft;
7793
- let scrollableTop;
7794
- let convertScrollablePosition;
7795
-
7796
- // getPositionedParent, not raw .offsetParent — offsetParent is null for a
7797
- // position: fixed element, and also for one promoted to the top layer
7798
- // (e.g. a <dialog>/[popover] being dragged by its own handle), which
7799
- // crashes the fixed-position lookup below (findSelfOrAncestorFixedPosition
7800
- // assumes a real starting element, not null). getPositionedParent never
7801
- // returns null (document.documentElement instead — see its own doc).
7802
- const positionedParent = getPositionedParent(elementToMove || element);
7803
- const scrollContainer = getScrollContainer(element);
7804
- const [getPositionOffsets, getScrollOffsets] = createGetOffsets({
7805
- positionedParent,
7806
- referencePositionedParent: referenceElement
7807
- ? getPositionedParent(referenceElement)
7808
- : positionedParent,
7809
- scrollContainer,
7810
- referenceScrollContainer: referenceElement
7811
- ? getScrollContainer(referenceElement)
7812
- : scrollContainer,
7813
- });
7748
+ const isolateInteractions = (elements) => {
7749
+ const cleanupCallbackSet = new Set();
7750
+ const cleanup = () => {
7751
+ for (const cleanupCallback of cleanupCallbackSet) {
7752
+ cleanupCallback();
7753
+ }
7754
+ cleanupCallbackSet.clear();
7755
+ };
7814
7756
 
7815
- {
7816
- [scrollableLeft, scrollableTop] = getScrollablePosition(
7817
- element,
7818
- scrollContainer,
7819
- );
7820
- const [positionOffsetLeft, positionOffsetTop] = getPositionOffsets();
7821
- scrollableLeft += positionOffsetLeft;
7822
- scrollableTop += positionOffsetTop;
7823
- }
7824
- {
7825
- convertScrollablePosition = (
7826
- scrollableLeftToConvert,
7827
- scrollableTopToConvert,
7828
- ) => {
7829
- const [positionOffsetLeft, positionOffsetTop] = getPositionOffsets();
7830
- const [scrollOffsetLeft, scrollOffsetTop] = getScrollOffsets();
7757
+ const toKeepInteractiveSet = new Set();
7758
+ const keepSelfAndAncestors = (el) => {
7759
+ if (toKeepInteractiveSet.has(el)) {
7760
+ return;
7761
+ }
7762
+ const associatedElements = getAssociatedElements(el);
7763
+ if (associatedElements) {
7764
+ for (const associatedElement of associatedElements) {
7765
+ keepSelfAndAncestors(associatedElement);
7766
+ }
7767
+ }
7831
7768
 
7832
- const positionedLeftWithoutScroll =
7833
- scrollableLeftToConvert + positionOffsetLeft;
7834
- const positionedTopWithoutScroll =
7835
- scrollableTopToConvert + positionOffsetTop;
7836
- const positionedLeft = positionedLeftWithoutScroll + scrollOffsetLeft;
7837
- const positionedTop = positionedTopWithoutScroll + scrollOffsetTop;
7769
+ // Add the element itself
7770
+ toKeepInteractiveSet.add(el);
7771
+ // Add all its ancestors up to document.body
7772
+ let ancestor = el.parentNode;
7773
+ while (ancestor && ancestor !== document.body) {
7774
+ toKeepInteractiveSet.add(ancestor);
7775
+ ancestor = ancestor.parentNode;
7776
+ }
7777
+ };
7838
7778
 
7839
- return [positionedLeft, positionedTop];
7840
- };
7779
+ // Build set of elements to keep interactive
7780
+ for (const element of elements) {
7781
+ keepSelfAndAncestors(element);
7841
7782
  }
7842
- return [scrollableLeft, scrollableTop, convertScrollablePosition];
7843
- };
7844
-
7845
- const getScrollablePosition = (element, scrollContainer) => {
7846
- const { left: elementViewportLeft, top: elementViewportTop } =
7847
- element.getBoundingClientRect();
7848
- const scrollContainerIsDocument = scrollContainer === documentElement;
7849
- if (scrollContainerIsDocument) {
7850
- return [elementViewportLeft, elementViewportTop];
7783
+ // backdrop elements are meant to control interactions happening at document level
7784
+ // and should stay interactive
7785
+ const backdropElements = document.querySelectorAll("[data-backdrop]");
7786
+ for (const backdropElement of backdropElements) {
7787
+ keepSelfAndAncestors(backdropElement);
7851
7788
  }
7852
- const { left: scrollContainerLeft, top: scrollContainerTop } =
7853
- scrollContainer.getBoundingClientRect();
7854
- const scrollableLeft = elementViewportLeft - scrollContainerLeft;
7855
- const scrollableTop = elementViewportTop - scrollContainerTop;
7856
-
7857
- return [scrollableLeft, scrollableTop];
7858
- };
7859
7789
 
7860
- const createGetOffsets = ({
7861
- positionedParent,
7862
- referencePositionedParent,
7863
- scrollContainer,
7864
- referenceScrollContainer,
7865
- }) => {
7866
- const samePositionedParent = positionedParent === referencePositionedParent;
7867
- const getScrollOffsets = createGetScrollOffsets(
7868
- scrollContainer,
7869
- referenceScrollContainer,
7870
- positionedParent,
7871
- samePositionedParent,
7872
- );
7790
+ const setInert = (el) => {
7791
+ if (toKeepInteractiveSet.has(el)) {
7792
+ // element should stay interactive
7793
+ return;
7794
+ }
7795
+ const restoreAttributes = setAttributes(el, {
7796
+ inert: "",
7797
+ });
7798
+ cleanupCallbackSet.add(() => {
7799
+ restoreAttributes();
7800
+ });
7801
+ };
7873
7802
 
7874
- if (samePositionedParent) {
7875
- return [() => [0, 0], getScrollOffsets];
7876
- }
7803
+ const makeElementInertSelectivelyOrCompletely = (el) => {
7804
+ // If this element should stay interactive, keep it active
7805
+ if (toKeepInteractiveSet.has(el)) {
7806
+ return;
7807
+ }
7877
7808
 
7878
- // parents are different, oh boy let's go
7879
- // The overlay case is problematic because the overlay adjust its position to the target dynamically
7880
- // This creates something complex to support properly.
7881
- // When overlay is fixed we there will never be any offset
7882
- // When overlay is absolute there is a diff relative to the scroll
7883
- // and eventually if the overlay is positioned differently than the other parent
7884
- if (isOverlayOf(positionedParent, referencePositionedParent)) {
7885
- return createGetOffsetsForOverlay(
7886
- positionedParent,
7887
- referencePositionedParent,
7888
- {
7889
- scrollContainer,
7890
- referenceScrollContainer,
7891
- getScrollOffsets,
7892
- },
7893
- );
7894
- }
7895
- if (isOverlayOf(referencePositionedParent, positionedParent)) {
7896
- return createGetOffsetsForOverlay(
7897
- referencePositionedParent,
7898
- positionedParent,
7899
- {
7900
- scrollContainer,
7901
- referenceScrollContainer,
7902
- getScrollOffsets,
7903
- },
7809
+ // Since we put all ancestors in toKeepInteractiveSet, if this element
7810
+ // is not in the set, we can check if any of its direct children are.
7811
+ // If none of the direct children are in the set, then no descendants are either.
7812
+ const children = Array.from(el.children);
7813
+ const hasInteractiveChildren = children.some((child) =>
7814
+ toKeepInteractiveSet.has(child),
7904
7815
  );
7905
- }
7906
- const scrollContainerIsDocument = scrollContainer === documentElement;
7907
- if (scrollContainerIsDocument) {
7908
- // Document case: getBoundingClientRect already includes document scroll effects
7909
- // Add current scroll position to get the static offset
7910
- const getPositionOffsetsDocumentScrolling = () => {
7911
- const { scrollLeft: documentScrollLeft, scrollTop: documentScrollTop } =
7912
- scrollContainer;
7913
- const aRect = positionedParent.getBoundingClientRect();
7914
- const bRect = referencePositionedParent.getBoundingClientRect();
7915
- const aLeft = aRect.left;
7916
- const aTop = aRect.top;
7917
- const bLeft = bRect.left;
7918
- const bTop = bRect.top;
7919
- const aLeftDocument = documentScrollLeft + aLeft;
7920
- const aTopDocument = documentScrollTop + aTop;
7921
- const bLeftDocument = documentScrollLeft + bLeft;
7922
- const bTopDocument = documentScrollTop + bTop;
7923
- const offsetLeft = bLeftDocument - aLeftDocument;
7924
- const offsetTop = bTopDocument - aTopDocument;
7925
- return [offsetLeft, offsetTop];
7926
- };
7927
- return [getPositionOffsetsDocumentScrolling, getScrollOffsets];
7928
- }
7929
- // Custom scroll container case: account for container's position and scroll
7930
- const getPositionOffsetsCustomScrollContainer = () => {
7931
- const aRect = positionedParent.getBoundingClientRect();
7932
- const bRect = referencePositionedParent.getBoundingClientRect();
7933
- const aLeft = aRect.left;
7934
- const aTop = aRect.top;
7935
- const bLeft = bRect.left;
7936
- const bTop = bRect.top;
7937
7816
 
7938
- const scrollContainerRect = scrollContainer.getBoundingClientRect();
7939
- const offsetLeft =
7940
- bLeft - aLeft + scrollContainer.scrollLeft - scrollContainerRect.left;
7941
- const offsetTop =
7942
- bTop - aTop + scrollContainer.scrollTop - scrollContainerRect.top;
7943
- return [offsetLeft, offsetTop];
7944
- };
7945
- return [getPositionOffsetsCustomScrollContainer, getScrollOffsets];
7946
- };
7947
- const createGetOffsetsForOverlay = (
7948
- overlay,
7949
- overlayTarget,
7950
- { scrollContainer, referenceScrollContainer, getScrollOffsets },
7951
- ) => {
7952
- const sameScrollContainer = scrollContainer === referenceScrollContainer;
7953
- const scrollContainerIsDocument =
7954
- scrollContainer === document.documentElement;
7955
- const referenceScrollContainerIsDocument =
7956
- referenceScrollContainer === documentElement;
7817
+ if (!hasInteractiveChildren) {
7818
+ // No interactive descendants, make the entire element inert
7819
+ setInert(el);
7820
+ return;
7821
+ }
7957
7822
 
7958
- if (getComputedStyle(overlay).position === "fixed") {
7959
- if (referenceScrollContainerIsDocument) {
7960
- const getPositionOffsetsFixedOverlay = () => {
7961
- return [0, 0];
7962
- };
7963
- return [getPositionOffsetsFixedOverlay, getScrollOffsets];
7823
+ // Some children need to stay interactive, process them selectively
7824
+ for (const child of children) {
7825
+ makeElementInertSelectivelyOrCompletely(child);
7964
7826
  }
7965
- const getPositionOffsetsFixedOverlay = () => {
7966
- const scrollContainerRect = scrollContainer.getBoundingClientRect();
7967
- const referenceScrollContainerRect =
7968
- referenceScrollContainer.getBoundingClientRect();
7969
- let offsetLeftBetweenScrollContainers =
7970
- referenceScrollContainerRect.left - scrollContainerRect.left;
7971
- let offsetTopBetweenScrollContainers =
7972
- referenceScrollContainerRect.top - scrollContainerRect.top;
7973
- if (scrollContainerIsDocument) {
7974
- offsetLeftBetweenScrollContainers -= scrollContainer.scrollLeft;
7975
- offsetTopBetweenScrollContainers -= scrollContainer.scrollTop;
7976
- }
7977
- return [
7978
- -offsetLeftBetweenScrollContainers,
7979
- -offsetTopBetweenScrollContainers,
7980
- ];
7981
- };
7982
- return [getPositionOffsetsFixedOverlay, getScrollOffsets];
7827
+ };
7828
+
7829
+ // Apply inert to all top-level elements that aren't in our keep-interactive set
7830
+ const bodyChildren = Array.from(document.body.children);
7831
+ for (const child of bodyChildren) {
7832
+ makeElementInertSelectivelyOrCompletely(child);
7983
7833
  }
7984
7834
 
7985
- const getPositionOffsetsOverlay = () => {
7986
- if (sameScrollContainer) {
7987
- const overlayRect = overlay.getBoundingClientRect();
7988
- const overlayTargetRect = overlayTarget.getBoundingClientRect();
7989
- const overlayLeft = overlayRect.left;
7990
- const overlayTop = overlayRect.top;
7991
- let overlayTargetLeft = overlayTargetRect.left;
7992
- let overlayTargetTop = overlayTargetRect.top;
7993
- if (scrollContainerIsDocument) {
7994
- overlayTargetLeft += scrollContainer.scrollLeft;
7995
- overlayTargetTop += scrollContainer.scrollTop;
7996
- }
7997
- const offsetLeftBetweenTargetAndOverlay = overlayTargetLeft - overlayLeft;
7998
- const offsetTopBetweenTargetAndOverlay = overlayTargetTop - overlayTop;
7999
- return [
8000
- -scrollContainer.scrollLeft + offsetLeftBetweenTargetAndOverlay,
8001
- -scrollContainer.scrollTop + offsetTopBetweenTargetAndOverlay,
8002
- ];
8003
- }
8004
-
8005
- const scrollContainerRect = scrollContainer.getBoundingClientRect();
8006
- const referenceScrollContainerRect =
8007
- referenceScrollContainer.getBoundingClientRect();
8008
- let scrollContainerLeft = scrollContainerRect.left;
8009
- let scrollContainerTop = scrollContainerRect.top;
8010
- let referenceScrollContainerLeft = referenceScrollContainerRect.left;
8011
- let referenceScrollContainerTop = referenceScrollContainerRect.top;
8012
- if (scrollContainerIsDocument) {
8013
- scrollContainerLeft += scrollContainer.scrollLeft;
8014
- scrollContainerTop += scrollContainer.scrollTop;
8015
- }
8016
- const offsetLeftBetweenScrollContainers =
8017
- referenceScrollContainerLeft - scrollContainerLeft;
8018
- const offsetTopBetweenScrollContainers =
8019
- referenceScrollContainerTop - scrollContainerTop;
8020
- return [
8021
- -offsetLeftBetweenScrollContainers - referenceScrollContainer.scrollLeft,
8022
- -offsetTopBetweenScrollContainers - referenceScrollContainer.scrollTop,
8023
- ];
8024
- };
8025
- const getScrollOffsetsOverlay = () => {
8026
- if (sameScrollContainer) {
8027
- return [scrollContainer.scrollLeft, scrollContainer.scrollTop];
8028
- }
8029
-
8030
- const scrollContainerRect = scrollContainer.getBoundingClientRect();
8031
- const referenceScrollContainerRect =
8032
- referenceScrollContainer.getBoundingClientRect();
8033
- let offsetLeftBetweenScrollContainers =
8034
- referenceScrollContainerRect.left - scrollContainerRect.left;
8035
- let offsetTopBetweenScrollContainers =
8036
- referenceScrollContainerRect.top - scrollContainerRect.top;
8037
- if (scrollContainerIsDocument) {
8038
- offsetLeftBetweenScrollContainers -= scrollContainer.scrollLeft;
8039
- offsetTopBetweenScrollContainers -= scrollContainer.scrollTop;
8040
- }
8041
-
8042
- return [
8043
- referenceScrollContainer.scrollLeft + offsetLeftBetweenScrollContainers,
8044
- referenceScrollContainer.scrollTop + offsetTopBetweenScrollContainers,
8045
- ];
7835
+ return () => {
7836
+ cleanup();
8046
7837
  };
8047
- return [getPositionOffsetsOverlay, getScrollOffsetsOverlay];
8048
7838
  };
8049
- const isOverlayOf = (element, potentialTarget) => {
8050
- const overlayForAttribute = element.getAttribute("data-overlay-for");
8051
- if (!overlayForAttribute) {
8052
- return false;
8053
- }
8054
- const overlayTarget = document.querySelector(`#${overlayForAttribute}`);
8055
- if (!overlayTarget) {
8056
- return false;
8057
- }
8058
- if (overlayTarget === potentialTarget) {
8059
- return true;
7839
+
7840
+ installImportMetaCssBuild(import.meta);/**
7841
+ * Drag Gesture System
7842
+ *
7843
+ * TODO: rename moveX/moveY en juste x/y
7844
+ * puisque move c'est perturbant sachant que c'est drag + scroll
7845
+ * et que drag c'est juste la partie mouvement de la souris
7846
+ *
7847
+ * donc juste x/y ca seras surement mieux
7848
+ *
7849
+ */
7850
+ const css$5 = /* css */`
7851
+ .navi_drag_gesture_backdrop {
7852
+ position: fixed;
7853
+ inset: 0;
7854
+ /* A finger dragging must not also pan the page under it. The backdrop is
7855
+ the only element the finger can be over once the gesture is running. */
7856
+ touch-action: none;
7857
+ user-select: none;
8060
7858
  }
8061
- const overlayTargetPositionedParent = getPositionedParent(overlayTarget);
8062
- if (overlayTargetPositionedParent === potentialTarget) {
8063
- return true;
7859
+ /* Chrome matches :focus-visible on a programmatic focus, so focusing what the
7860
+ gesture holds draws a ring around an object the user already has under the
7861
+ pointer — a frame blinking for the length of the gesture, saying something
7862
+ the finger knows. The ring stays whole where it earns its place: at the
7863
+ keyboard, outside any gesture.
7864
+ focus({ focusVisible: false }) would say the intent better but does not
7865
+ hold — Chrome's heuristic does not always obey the option (see
7866
+ isMatchingFocusVisible). */
7867
+ [data-drag-focus]:focus-visible {
7868
+ outline: none;
8064
7869
  }
8065
- return false;
8066
- };
8067
-
8068
- const { documentElement } =
8069
- typeof document === "object" ? document : { documentElement: null };
8070
-
8071
- const createGetScrollOffsets = (
8072
- scrollContainer,
8073
- referenceScrollContainer,
8074
- positionedParent,
8075
- samePositionedParent,
8076
- ) => {
8077
- const getGetScrollOffsetsSameContainer = () => {
8078
- const scrollContainerIsDocument = scrollContainer === documentElement;
8079
- // I don't really get why we have to add scrollLeft (scrollLeft at grab)
8080
- // to properly position the element in this scenario
8081
- // It happens since we use translateX to position the element
8082
- // Or maybe since something else. In any case it works
8083
- const { scrollLeft, scrollTop } = samePositionedParent
8084
- ? { scrollLeft: 0, scrollTop: 0 }
8085
- : referenceScrollContainer;
8086
- if (scrollContainerIsDocument) {
8087
- const fixedPosition = findSelfOrAncestorFixedPosition(positionedParent);
8088
- if (fixedPosition) {
8089
- const getScrollOffsetsFixed = () => {
8090
- const leftScrollToAdd = scrollLeft + fixedPosition[0];
8091
- const topScrollToAdd = scrollTop + fixedPosition[1];
8092
- return [leftScrollToAdd, topScrollToAdd];
8093
- };
8094
- return getScrollOffsetsFixed;
8095
- }
7870
+ `;
7871
+ import.meta.css = [css$5, "@jsenv/dom/src/interaction/drag/drag_gesture.js"];
7872
+ const createDragGestureController = (options = {}) => {
7873
+ const {
7874
+ name,
7875
+ onGrab,
7876
+ onDragStart,
7877
+ onDrag,
7878
+ onRelease,
7879
+ threshold = 5,
7880
+ direction: defaultDirection = {
7881
+ x: true,
7882
+ y: true
7883
+ },
7884
+ documentInteractions = "auto",
7885
+ backdrop = true,
7886
+ backdropZIndex = 999999
7887
+ } = options;
7888
+ const dragGestureController = {
7889
+ grab: null,
7890
+ gravViaPointer: null
7891
+ };
7892
+ const grab = ({
7893
+ element,
7894
+ direction = defaultDirection,
7895
+ event = new CustomEvent("programmatic"),
7896
+ grabX = 0,
7897
+ grabY = 0,
7898
+ cursor = "grabbing",
7899
+ scrollContainer = document.documentElement,
7900
+ layoutScrollableLeft: scrollableLeftAtGrab = 0,
7901
+ layoutScrollableTop: scrollableTopAtGrab = 0
7902
+ } = {}) => {
7903
+ if (!element) {
7904
+ throw new Error("element is required");
8096
7905
  }
8097
- const positionedParentIsInsideScrollContainer =
8098
- referenceScrollContainer === documentElement ||
8099
- referenceScrollContainer.contains(positionedParent);
8100
- if (!positionedParentIsInsideScrollContainer) {
8101
- // positionedParent is outside the scroll container (e.g. clone in document.body
8102
- // while tracking an element inside a custom scroll container).
8103
- // We must add the scroll at grab time as a frozen offset so that:
8104
- // - initial topDelta = 0 (clone starts at correct position)
8105
- // - auto-scroll doesn't double-move the clone (scroll changes cancel out in layout)
8106
- const scrollLeftAtGrab = referenceScrollContainer.scrollLeft;
8107
- const scrollTopAtGrab = referenceScrollContainer.scrollTop;
8108
- return () => [scrollLeft + scrollLeftAtGrab, scrollTop + scrollTopAtGrab];
7906
+ if (!direction.x && !direction.y) {
7907
+ return null;
8109
7908
  }
8110
- const getScrollOffsets = () => {
8111
- const leftScrollToAdd = scrollLeft + referenceScrollContainer.scrollLeft;
8112
- const topScrollToAdd = scrollTop + referenceScrollContainer.scrollTop;
8113
- return [leftScrollToAdd, topScrollToAdd];
7909
+ const [publishBeforeDrag, addBeforeDragCallback] = createPubSub();
7910
+ const [publishDrag, addDragCallback] = createPubSub();
7911
+ const [publishRelease, addReleaseCallback] = createPubSub();
7912
+ if (onDrag) {
7913
+ addDragCallback(onDrag);
7914
+ }
7915
+ if (onRelease) {
7916
+ addReleaseCallback(onRelease);
7917
+ }
7918
+ const scrollLeftAtGrab = scrollContainer.scrollLeft;
7919
+ const scrollTopAtGrab = scrollContainer.scrollTop;
7920
+ const leftAtGrab = scrollLeftAtGrab + scrollableLeftAtGrab;
7921
+ const topAtGrab = scrollTopAtGrab + scrollableTopAtGrab;
7922
+ const createLayout = (x, y) => {
7923
+ const {
7924
+ scrollLeft,
7925
+ scrollTop
7926
+ } = scrollContainer;
7927
+ const left = scrollableLeftAtGrab + x;
7928
+ const top = scrollableTopAtGrab + y;
7929
+ const scrollableLeft = left - scrollLeft;
7930
+ const scrollableTop = top - scrollTop;
7931
+ const layoutProps = {
7932
+ // Raw input coordinates (dragX - grabX + scrollContainer.scrollLeft)
7933
+ x,
7934
+ y,
7935
+ // container scrolls when layout is created
7936
+ scrollLeft,
7937
+ scrollTop,
7938
+ // Position relative to container excluding scrolls
7939
+ scrollableLeft,
7940
+ scrollableTop,
7941
+ // Position relative to container including scrolls
7942
+ left,
7943
+ top,
7944
+ // Delta since grab (number representing how much we dragged)
7945
+ xDelta: left - leftAtGrab,
7946
+ yDelta: top - topAtGrab
7947
+ };
7948
+ return layoutProps;
8114
7949
  };
8115
- return getScrollOffsets;
8116
- };
7950
+ const grabLayout = createLayout(grabX + scrollContainer.scrollLeft, grabY + scrollContainer.scrollTop);
7951
+ const gestureInfo = {
7952
+ name,
7953
+ direction,
7954
+ started: !threshold,
7955
+ status: "grabbed",
7956
+ element,
7957
+ scrollContainer,
7958
+ grabX,
7959
+ // x grab coordinate (excluding scroll)
7960
+ grabY,
7961
+ // y grab coordinate (excluding scroll)
7962
+ grabLayout,
7963
+ leftAtGrab,
7964
+ topAtGrab,
7965
+ dragX: grabX,
7966
+ // coordinate of the last drag (excluding scroll of the scrollContainer)
7967
+ dragY: grabY,
7968
+ // coordinate of the last drag (excluding scroll of the scrollContainer)
7969
+ layout: grabLayout,
7970
+ isGoingUp: undefined,
7971
+ isGoingDown: undefined,
7972
+ isGoingLeft: undefined,
7973
+ isGoingRight: undefined,
7974
+ intentGoingUp: false,
7975
+ intentGoingDown: false,
7976
+ intentGoingLeft: false,
7977
+ intentGoingRight: false,
7978
+ // How fast the pointer is going, in px/ms, signed per axis
7979
+ // (see measureVelocity)
7980
+ velocityX: 0,
7981
+ velocityY: 0,
7982
+ velocity: 0,
7983
+ // metadata about interaction sources
7984
+ grabEvent: event,
7985
+ dragEvent: null,
7986
+ releaseEvent: null
7987
+ };
7988
+ definePropertyAsReadOnly(gestureInfo, "name");
7989
+ definePropertyAsReadOnly(gestureInfo, "direction");
7990
+ definePropertyAsReadOnly(gestureInfo, "scrollContainer");
7991
+ definePropertyAsReadOnly(gestureInfo, "grabX");
7992
+ definePropertyAsReadOnly(gestureInfo, "grabY");
7993
+ definePropertyAsReadOnly(gestureInfo, "grabLayout");
7994
+ definePropertyAsReadOnly(gestureInfo, "leftAtGrab");
7995
+ definePropertyAsReadOnly(gestureInfo, "topAtGrab");
7996
+ definePropertyAsReadOnly(gestureInfo, "grabEvent");
8117
7997
 
8118
- const sameScrollContainer = scrollContainer === referenceScrollContainer;
8119
- const getScrollOffsetsSameContainer = getGetScrollOffsetsSameContainer();
8120
- if (sameScrollContainer) {
8121
- return getScrollOffsetsSameContainer;
8122
- }
8123
- const getScrollOffsetsDifferentContainers = () => {
8124
- const [scrollLeftToAdd, scrollTopToAdd] = getScrollOffsetsSameContainer();
8125
- const rect = scrollContainer.getBoundingClientRect();
8126
- const referenceRect = referenceScrollContainer.getBoundingClientRect();
8127
- const leftDiff = referenceRect.left - rect.left;
8128
- const topDiff = referenceRect.top - rect.top;
8129
- return [scrollLeftToAdd + leftDiff, scrollTopToAdd + topDiff];
8130
- };
8131
- return getScrollOffsetsDifferentContainers;
8132
- };
8133
- const getDragCoordinates = (
8134
- element,
8135
- scrollContainer = getScrollContainer(element),
8136
- ) => {
8137
- const [scrollableLeft, scrollableTop] = getScrollablePosition(
8138
- element,
8139
- scrollContainer,
8140
- );
8141
- const { scrollLeft, scrollTop } = scrollContainer;
8142
- const leftRelativeToScrollContainer = scrollableLeft + scrollLeft;
8143
- const topRelativeToScrollContainer = scrollableTop + scrollTop;
8144
- return [leftRelativeToScrollContainer, topRelativeToScrollContainer];
8145
- };
8146
-
8147
- const installImportMetaCssBuild = (importMeta) => {
8148
- const IMPORT_META_CSS_BUILD = "jsenv_import_meta_css_build";
7998
+ // Where the pointer IS is not where it is going: throwing something is a
7999
+ // matter of speed, and the gesture is the only place that sees the timing of
8000
+ // the events it receives.
8001
+ const measureVelocity = createVelocityMeter(grabX, grabY);
8002
+ document_interactions: {
8003
+ if (documentInteractions === "manual") {
8004
+ break document_interactions;
8005
+ }
8006
+ /*
8007
+ GOAL: Take control of document-level interactions during drag gestures
8008
+
8009
+ WHY: During drag operations, we need to prevent conflicting user interactions that would:
8010
+ 1. Interfere with the drag gesture (competing pointer events, focus changes)
8011
+ 2. Break the visual feedback (inconsistent cursors, hover states)
8012
+ 3. Cause unwanted scrolling (keyboard shortcuts, wheel events in restricted directions)
8013
+ 4. Create accessibility issues (focus jumping, screen reader confusion)
8014
+ STRATEGY: Create a controlled interaction environment by:
8015
+ 1. VISUAL CONTROL: Use a backdrop to unify cursor appearance and block pointer events
8016
+ 2. INTERACTION ISOLATION: Make non-dragged elements inert to prevent interference
8017
+ 3. FOCUS MANAGEMENT: Control focus location and prevent focus changes during drag
8018
+ 4. SELECTIVE SCROLLING: Allow scrolling only in directions supported by the drag gesture
8019
+ IMPLEMENTATION:
8020
+ */
8149
8021
 
8150
- if (importMeta.css === IMPORT_META_CSS_BUILD) {
8151
- return;
8152
- }
8022
+ // 1. INTERACTION ISOLATION: Make everything except the dragged element inert
8023
+ // This prevents keyboard events, pointer interactions, and screen reader navigation
8024
+ // on non-relevant elements during the drag operation
8025
+ const cleanupInert = isolateInteractions([element, ...Array.from(document.querySelectorAll("[data-droppable]"))]);
8026
+ addReleaseCallback(() => {
8027
+ cleanupInert();
8028
+ });
8153
8029
 
8154
- const stylesheetMap = new Map();
8155
- const adopt = (url, value) => {
8156
- const stylesheet = new CSSStyleSheet({ baseUrl: importMeta.url });
8157
- stylesheet.replaceSync(value);
8158
- stylesheetMap.set(url, stylesheet);
8159
- document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet];
8160
- };
8161
- const update = (url, value) => {
8162
- stylesheetMap.get(url).replaceSync(value);
8163
- };
8164
- const remove = (url) => {
8165
- const stylesheet = stylesheetMap.get(url);
8166
- document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
8167
- (s) => s !== stylesheet,
8168
- );
8169
- stylesheetMap.delete(url);
8170
- };
8030
+ // 2. VISUAL CONTROL: Backdrop for consistent cursor and pointer event blocking
8031
+ if (backdrop) {
8032
+ const backdropElement = document.createElement("div");
8033
+ backdropElement.className = "navi_drag_gesture_backdrop";
8034
+ backdropElement.ariaHidden = "true";
8035
+ backdropElement.setAttribute("data-backdrop", "");
8036
+ backdropElement.style.zIndex = backdropZIndex;
8037
+ backdropElement.style.cursor = cursor;
8171
8038
 
8172
- const currentCssSourceMap = new Map();
8173
- Object.defineProperty(importMeta, "css", {
8174
- configurable: true,
8175
- get() {
8176
- return IMPORT_META_CSS_BUILD;
8177
- },
8178
- set([value, url]) {
8179
- if (value === undefined) {
8180
- if (stylesheetMap.has(url)) {
8181
- remove(url);
8182
- currentCssSourceMap.delete(url);
8039
+ // Handle wheel events on backdrop for directionally-constrained drag gestures
8040
+ // (e.g., table column resize should only allow horizontal scrolling)
8041
+ if (!direction.x || !direction.y) {
8042
+ backdropElement.onwheel = e => {
8043
+ e.preventDefault();
8044
+ const scrollX = direction.x ? e.deltaX : 0;
8045
+ const scrollY = direction.y ? e.deltaY : 0;
8046
+ scrollContainer.scrollBy({
8047
+ left: scrollX,
8048
+ top: scrollY,
8049
+ behavior: "auto"
8050
+ });
8051
+ };
8183
8052
  }
8184
- return;
8185
- }
8186
- if (!stylesheetMap.has(url)) {
8187
- adopt(url, value);
8188
- currentCssSourceMap.set(url, value);
8189
- } else if (currentCssSourceMap.get(url) !== value) {
8190
- update(url, value);
8191
- currentCssSourceMap.set(url, value);
8053
+ document.body.appendChild(backdropElement);
8054
+ addReleaseCallback(() => {
8055
+ backdropElement.remove();
8056
+ });
8192
8057
  }
8193
- },
8194
- });
8195
- };
8196
8058
 
8197
- /**
8198
- * Isolates user interactions to only the specified elements, making everything else non-interactive.
8199
- *
8200
- * This creates a controlled interaction environment where only the target elements (and their ancestors)
8201
- * can receive user input like clicks, keyboard events, focus, etc. All other DOM elements become
8202
- * non-interactive, preventing conflicting or unwanted interactions during critical operations
8203
- * like drag gestures, modal dialogs, or complex UI states.
8204
- *
8205
- * The function uses the `inert` attribute to achieve this isolation, applying it strategically
8206
- * to parts of the DOM tree while preserving the interactive elements and their ancestor chains.
8207
- *
8208
- * Example DOM structure and inert application:
8209
- *
8210
- * Before calling isolateInteractions:
8211
- * ```
8212
- * <body>
8213
- * <header>...</header>
8214
- * <main>
8215
- * <div>
8216
- * <span>some content</span>
8217
- * <div class="modal">modal content</div>
8218
- * <span>more content</span>
8219
- * </div>
8220
- * <aside inert>already inert</aside>
8221
- * <div class="dropdown">dropdown menu</div>
8222
- * </main>
8223
- * <footer>...</footer>
8224
- * </body>
8225
- * ```
8226
- *
8227
- * After calling isolateInteractions([modal, dropdown]):
8228
- * ```
8229
- * <body>
8230
- * <header inert>...</header> ← made inert (no active descendants)
8231
- * <main> ← not inert because it contains active elements
8232
- * <div> ← not inert because it contains .modal
8233
- * <span inert>some content</span> ← made inert selectively
8234
- * <div class="modal">modal content</div> ← stays active
8235
- * <span inert>more content</span> ← made inert selectively
8236
- * </div>
8237
- * <aside inert>already inert</aside>
8238
- * <div class="dropdown">dropdown menu</div> ← stays active
8239
- * </main>
8240
- * <footer inert>...</footer>
8241
- * </body>
8242
- * ```
8243
- *
8244
- * After calling cleanup():
8245
- * ```
8246
- * <body>
8247
- * <header>...</header>
8248
- * <main>
8249
- * <div>
8250
- * <span>some content</span>
8251
- * <div class="modal">modal content</div>
8252
- * <span>more content</span>
8253
- * </div>
8254
- * <aside inert>already inert</aside> ← [inert] preserved
8255
- * <div class="dropdown">dropdown menu</div>
8256
- * </main>
8257
- * <footer>...</footer>
8258
- * </body>
8259
- * ```
8260
- *
8261
- * @param {Array<Element>} elements - Array of elements to keep interactive (non-inert)
8262
- * @returns {Function} cleanup - Function to restore original inert states
8263
- */
8264
- const isolateInteractions = (elements) => {
8265
- const cleanupCallbackSet = new Set();
8266
- const cleanup = () => {
8267
- for (const cleanupCallback of cleanupCallbackSet) {
8268
- cleanupCallback();
8269
- }
8270
- cleanupCallbackSet.clear();
8271
- };
8059
+ // 3. FOCUS MANAGEMENT: Control and stabilize focus during drag
8060
+ const {
8061
+ activeElement
8062
+ } = document;
8063
+ const focusableElement = findFocusable(element);
8064
+ // Focus the dragged element (or document.body as fallback) to establish clear focus context
8065
+ // This also ensure any keydown event listened by the currently focused element
8066
+ // won't be available during drag
8067
+ const elementToFocus = focusableElement || document.body;
8068
+ elementToFocus.setAttribute("data-drag-focus", "");
8069
+ elementToFocus.focus({
8070
+ preventScroll: true
8071
+ });
8072
+ addReleaseCallback(() => {
8073
+ elementToFocus.removeAttribute("data-drag-focus");
8074
+ // Restore original focus on release
8075
+ activeElement.focus({
8076
+ preventScroll: true
8077
+ });
8078
+ });
8079
+ // Prevent Tab navigation entirely (focus should stay stable)
8080
+ const onkeydown = e => {
8081
+ if (e.key === "Tab") {
8082
+ e.preventDefault();
8083
+ return;
8084
+ }
8085
+ };
8086
+ document.addEventListener("keydown", onkeydown);
8087
+ addReleaseCallback(() => {
8088
+ document.removeEventListener("keydown", onkeydown);
8089
+ });
8272
8090
 
8273
- const toKeepInteractiveSet = new Set();
8274
- const keepSelfAndAncestors = (el) => {
8275
- if (toKeepInteractiveSet.has(el)) {
8276
- return;
8277
- }
8278
- const associatedElements = getAssociatedElements(el);
8279
- if (associatedElements) {
8280
- for (const associatedElement of associatedElements) {
8281
- keepSelfAndAncestors(associatedElement);
8091
+ // 4. SELECTIVE SCROLLING: Allow keyboard scrolling only in supported directions
8092
+ {
8093
+ const onDocumentKeydown = keyboardEvent => {
8094
+ // Vertical scrolling keys - prevent if vertical movement not supported
8095
+ if (keyboardEvent.key === "ArrowUp" || keyboardEvent.key === "ArrowDown" || keyboardEvent.key === " " || keyboardEvent.key === "PageUp" || keyboardEvent.key === "PageDown" || keyboardEvent.key === "Home" || keyboardEvent.key === "End") {
8096
+ if (!direction.y) {
8097
+ keyboardEvent.preventDefault();
8098
+ }
8099
+ return;
8100
+ }
8101
+ // Horizontal scrolling keys - prevent if horizontal movement not supported
8102
+ if (keyboardEvent.key === "ArrowLeft" || keyboardEvent.key === "ArrowRight") {
8103
+ if (!direction.x) {
8104
+ keyboardEvent.preventDefault();
8105
+ }
8106
+ return;
8107
+ }
8108
+ };
8109
+ document.addEventListener("keydown", onDocumentKeydown);
8110
+ addReleaseCallback(() => {
8111
+ document.removeEventListener("keydown", onDocumentKeydown);
8112
+ });
8282
8113
  }
8283
8114
  }
8284
8115
 
8285
- // Add the element itself
8286
- toKeepInteractiveSet.add(el);
8287
- // Add all its ancestors up to document.body
8288
- let ancestor = el.parentNode;
8289
- while (ancestor && ancestor !== document.body) {
8290
- toKeepInteractiveSet.add(ancestor);
8291
- ancestor = ancestor.parentNode;
8116
+ // Set up scroll event handling to adjust drag position when scrolling occurs
8117
+ {
8118
+ let isHandlingScroll = false;
8119
+ const handleScroll = scrollEvent => {
8120
+ if (isHandlingScroll) {
8121
+ return;
8122
+ }
8123
+ isHandlingScroll = true;
8124
+ drag(gestureInfo.dragX, gestureInfo.dragY, {
8125
+ event: scrollEvent
8126
+ });
8127
+ isHandlingScroll = false;
8128
+ };
8129
+ const scrollEventReceiver = scrollContainer === document.documentElement ? document : scrollContainer;
8130
+ scrollEventReceiver.addEventListener("scroll", handleScroll, {
8131
+ passive: true
8132
+ });
8133
+ addReleaseCallback(() => {
8134
+ scrollEventReceiver.removeEventListener("scroll", handleScroll, {
8135
+ passive: true
8136
+ });
8137
+ });
8292
8138
  }
8293
- };
8294
-
8295
- // Build set of elements to keep interactive
8296
- for (const element of elements) {
8297
- keepSelfAndAncestors(element);
8298
- }
8299
- // backdrop elements are meant to control interactions happening at document level
8300
- // and should stay interactive
8301
- const backdropElements = document.querySelectorAll("[data-backdrop]");
8302
- for (const backdropElement of backdropElements) {
8303
- keepSelfAndAncestors(backdropElement);
8304
- }
8139
+ const determineDragData = ({
8140
+ dragX,
8141
+ dragY,
8142
+ dragEvent,
8143
+ isRelease = false
8144
+ }) => {
8145
+ // === ÉTAT INITIAL (au moment du grab) ===
8146
+ const {
8147
+ grabX,
8148
+ grabY,
8149
+ grabLayout
8150
+ } = gestureInfo;
8151
+ // === CE QUI EST DEMANDÉ (où on veut aller) ===
8152
+ // Calcul de la direction basé sur le mouvement précédent
8153
+ // (ne tient pas compte du mouvement final une fois les contraintes appliquées)
8154
+ // (ici on veut connaitre l'intention)
8155
+ // on va utiliser cela pour savoir vers où on scroll si nécéssaire par ex
8156
+ const currentDragX = gestureInfo.dragX;
8157
+ const currentDragY = gestureInfo.dragY;
8158
+ const isGoingLeft = dragX < currentDragX;
8159
+ const isGoingRight = dragX > currentDragX;
8160
+ const isGoingUp = dragY < currentDragY;
8161
+ const isGoingDown = dragY > currentDragY;
8162
+ const layoutXRequested = direction.x ? scrollContainer.scrollLeft + (dragX - grabX) : grabLayout.scrollLeft;
8163
+ const layoutYRequested = direction.y ? scrollContainer.scrollTop + (dragY - grabY) : grabLayout.scrollTop;
8164
+ const layoutRequested = createLayout(layoutXRequested, layoutYRequested);
8165
+ const currentLayout = gestureInfo.layout;
8166
+ let layout;
8167
+ if (layoutRequested.x === currentLayout.x && layoutRequested.y === currentLayout.y) {
8168
+ layout = currentLayout;
8169
+ } else {
8170
+ // === APPLICATION DES CONTRAINTES ===
8171
+ let layoutConstrained = layoutRequested;
8172
+ const limitLayout = (left, top) => {
8173
+ layoutConstrained = createLayout(left === undefined ? layoutConstrained.x : left - scrollableLeftAtGrab, top === undefined ? layoutConstrained.y : top - scrollableTopAtGrab);
8174
+ };
8175
+ publishBeforeDrag(layoutRequested, currentLayout, limitLayout, {
8176
+ dragEvent,
8177
+ isRelease
8178
+ });
8179
+ // === ÉTAT FINAL ===
8180
+ layout = layoutConstrained;
8181
+ }
8182
+ const dragData = {
8183
+ dragX,
8184
+ dragY,
8185
+ layout,
8186
+ isGoingLeft,
8187
+ isGoingRight,
8188
+ isGoingUp,
8189
+ isGoingDown,
8190
+ status: isRelease ? "released" : "dragging",
8191
+ dragEvent: isRelease ? gestureInfo.dragEvent : dragEvent,
8192
+ releaseEvent: isRelease ? dragEvent : null
8193
+ };
8194
+ if (isRelease) {
8195
+ return dragData;
8196
+ }
8197
+ if (!gestureInfo.started && threshold) {
8198
+ const deltaX = Math.abs(dragX - grabX);
8199
+ const deltaY = Math.abs(dragY - grabY);
8200
+ if (direction.x && direction.y) {
8201
+ // Both directions: check both axes
8202
+ if (deltaX < threshold && deltaY < threshold) {
8203
+ return dragData;
8204
+ }
8205
+ } else if (direction.x) {
8206
+ if (deltaX < threshold) {
8207
+ return dragData;
8208
+ }
8209
+ } else if (direction.y) {
8210
+ if (deltaY < threshold) {
8211
+ return dragData;
8212
+ }
8213
+ }
8214
+ dragData.started = true;
8215
+ }
8216
+ return dragData;
8217
+ };
8218
+ const markAsStarted = () => {
8219
+ // Suppress the click that the browser fires after pointerup following a real drag.
8220
+ // The capture phase runs before any element onClick handler.
8221
+ const suppressClick = clickEvent => {
8222
+ clickEvent.stopPropagation();
8223
+ clickEvent.preventDefault();
8224
+ stopSuppressingClick();
8225
+ };
8226
+ // That click is dispatched AFTER the pointerup that ends the drag, so
8227
+ // this cannot be taken down with the gesture — it would be gone one event
8228
+ // too early, and the drag would end on the link it started from being
8229
+ // followed. It goes once it has swallowed the click, or at the next press
8230
+ // if the drag produced none: a click is always preceded by a press, so a
8231
+ // suppressor that outlives one press can never reach the click of
8232
+ // another.
8233
+ const stopSuppressingClick = () => {
8234
+ document.removeEventListener("click", suppressClick, {
8235
+ capture: true
8236
+ });
8237
+ document.removeEventListener("pointerdown", stopSuppressingClick, {
8238
+ capture: true
8239
+ });
8240
+ };
8241
+ document.addEventListener("click", suppressClick, {
8242
+ capture: true
8243
+ });
8244
+ addReleaseCallback(() => {
8245
+ document.addEventListener("pointerdown", stopSuppressingClick, {
8246
+ capture: true
8247
+ });
8248
+ });
8249
+ // Everything this gesture puts on the document is in place, and undoable,
8250
+ // BEFORE anybody is told it started: a listener may end the gesture from
8251
+ // inside this very notification — that is how a press becomes a drag (see
8252
+ // dragAfterIntent, where the gesture that measured the distance releases
8253
+ // itself the moment it is confirmed). Set up afterwards, a listener would
8254
+ // be registering its own removal with a gesture that is already over, and
8255
+ // would then outlive it: what one sees is a click swallowed long after
8256
+ // the drag it belonged to.
8257
+ dispatchPublicCustomEvent(element, "navi_drag_start", {
8258
+ gestureInfo
8259
+ });
8260
+ onDragStart?.(gestureInfo);
8261
+ };
8305
8262
 
8306
- const setInert = (el) => {
8307
- if (toKeepInteractiveSet.has(el)) {
8308
- // element should stay interactive
8309
- return;
8310
- }
8311
- const restoreAttributes = setAttributes(el, {
8312
- inert: "",
8313
- });
8314
- cleanupCallbackSet.add(() => {
8315
- restoreAttributes();
8263
+ // Declares the gesture confirmed without waiting for the distance threshold,
8264
+ // for callers who established the intent some other way (a dedicated handle,
8265
+ // a long press).
8266
+ const start = () => {
8267
+ if (gestureInfo.started) {
8268
+ return;
8269
+ }
8270
+ gestureInfo.started = true;
8271
+ markAsStarted();
8272
+ };
8273
+ const drag = (dragX = gestureInfo.dragX,
8274
+ // Scroll container relative X coordinate
8275
+ dragY = gestureInfo.dragY,
8276
+ // Scroll container relative Y coordinate
8277
+ {
8278
+ event = new CustomEvent("programmatic"),
8279
+ isRelease = false
8280
+ } = {}) => {
8281
+ const dragData = determineDragData({
8282
+ dragX,
8283
+ dragY,
8284
+ dragEvent: event,
8285
+ isRelease
8286
+ });
8287
+ const [velocityX, velocityY] = measureVelocity(dragX, dragY);
8288
+ const startedPrevious = gestureInfo.started;
8289
+ const layoutPrevious = gestureInfo.layout;
8290
+ // previousGestureInfo = { ...gestureInfo };
8291
+ Object.assign(gestureInfo, dragData);
8292
+ gestureInfo.velocityX = velocityX;
8293
+ gestureInfo.velocityY = velocityY;
8294
+ gestureInfo.velocity = Math.hypot(velocityX, velocityY);
8295
+ if (gestureInfo.isGoingDown) {
8296
+ gestureInfo.intentGoingDown = true;
8297
+ gestureInfo.intentGoingUp = false;
8298
+ } else if (gestureInfo.isGoingUp) {
8299
+ gestureInfo.intentGoingUp = true;
8300
+ gestureInfo.intentGoingDown = false;
8301
+ }
8302
+ if (gestureInfo.isGoingRight) {
8303
+ gestureInfo.intentGoingRight = true;
8304
+ gestureInfo.intentGoingLeft = false;
8305
+ } else if (gestureInfo.isGoingLeft) {
8306
+ gestureInfo.intentGoingLeft = true;
8307
+ gestureInfo.intentGoingRight = false;
8308
+ }
8309
+ if (!startedPrevious && gestureInfo.started) {
8310
+ markAsStarted();
8311
+ }
8312
+ const someLayoutChange = gestureInfo.layout !== layoutPrevious;
8313
+ dispatchPublicCustomEvent(element, "navi_drag", {
8314
+ gestureInfo,
8315
+ someLayoutChange
8316
+ });
8317
+ publishDrag(gestureInfo,
8318
+ // we still publish drag event even when unchanged
8319
+ // because UI might need to adjust when document scrolls
8320
+ // even if nothing truly changes visually the element
8321
+ // can decide to stick to the scroll for example
8322
+ someLayoutChange);
8323
+ };
8324
+ const release = ({
8325
+ event = new CustomEvent("programmatic"),
8326
+ releaseX = gestureInfo.dragX,
8327
+ releaseY = gestureInfo.dragY
8328
+ } = {}) => {
8329
+ drag(releaseX, releaseY, {
8330
+ event,
8331
+ isRelease: true
8332
+ });
8333
+ dispatchPublicCustomEvent(element, "navi_drag_release", {
8334
+ gestureInfo
8335
+ });
8336
+ publishRelease(gestureInfo);
8337
+ };
8338
+ dispatchPublicCustomEvent(element, "navi_drag_grab", {
8339
+ gestureInfo
8316
8340
  });
8341
+ onGrab?.(gestureInfo);
8342
+ const dragGesture = {
8343
+ gestureInfo,
8344
+ addBeforeDragCallback,
8345
+ addDragCallback,
8346
+ addReleaseCallback,
8347
+ start,
8348
+ drag,
8349
+ release
8350
+ };
8351
+ return dragGesture;
8317
8352
  };
8318
-
8319
- const makeElementInertSelectivelyOrCompletely = (el) => {
8320
- // If this element should stay interactive, keep it active
8321
- if (toKeepInteractiveSet.has(el)) {
8322
- return;
8323
- }
8324
-
8325
- // Since we put all ancestors in toKeepInteractiveSet, if this element
8326
- // is not in the set, we can check if any of its direct children are.
8327
- // If none of the direct children are in the set, then no descendants are either.
8328
- const children = Array.from(el.children);
8329
- const hasInteractiveChildren = children.some((child) =>
8330
- toKeepInteractiveSet.has(child),
8331
- );
8332
-
8333
- if (!hasInteractiveChildren) {
8334
- // No interactive descendants, make the entire element inert
8335
- setInert(el);
8336
- return;
8353
+ dragGestureController.grab = grab;
8354
+ const initDragByPointer = (grabEvent, dragOptions, initializer) => {
8355
+ if (!isPrimaryButtonEvent(grabEvent)) {
8356
+ return null;
8337
8357
  }
8338
-
8339
- // Some children need to stay interactive, process them selectively
8340
- for (const child of children) {
8341
- makeElementInertSelectivelyOrCompletely(child);
8358
+ const target = grabEvent.target;
8359
+ if (!target.closest) {
8360
+ // target is a text node
8361
+ return null;
8342
8362
  }
8343
- };
8344
-
8345
- // Apply inert to all top-level elements that aren't in our keep-interactive set
8346
- const bodyChildren = Array.from(document.body.children);
8347
- for (const child of bodyChildren) {
8348
- makeElementInertSelectivelyOrCompletely(child);
8349
- }
8363
+ const mouseEventCoords = mouseEvent => {
8364
+ const {
8365
+ clientX,
8366
+ clientY
8367
+ } = mouseEvent;
8368
+ return [clientX, clientY];
8369
+ };
8370
+ const [grabX, grabY] = mouseEventCoords(grabEvent);
8371
+ const dragGesture = dragGestureController.grab({
8372
+ grabX,
8373
+ grabY,
8374
+ event: grabEvent,
8375
+ ...dragOptions
8376
+ });
8377
+ const dragViaPointer = dragEvent => {
8378
+ const [mouseDragX, mouseDragY] = mouseEventCoords(dragEvent);
8379
+ dragGesture.drag(mouseDragX, mouseDragY, {
8380
+ event: dragEvent
8381
+ });
8382
+ };
8383
+ const releaseViaPointer = mouseupEvent => {
8384
+ const [mouseReleaseX, mouseReleaseY] = mouseEventCoords(mouseupEvent);
8385
+ dragGesture.release({
8386
+ event: mouseupEvent,
8387
+ releaseX: mouseReleaseX,
8388
+ releaseY: mouseReleaseY
8389
+ });
8390
+ };
8391
+ dragGesture.dragViaPointer = dragViaPointer;
8392
+ dragGesture.releaseViaPointer = releaseViaPointer;
8393
+ const cleanup = initializer({
8394
+ onMove: dragViaPointer,
8395
+ onRelease: releaseViaPointer,
8396
+ gestureInfo: dragGesture.gestureInfo
8397
+ });
8398
+ dragGesture.addReleaseCallback(() => {
8399
+ cleanup();
8400
+ });
8401
+ return dragGesture;
8402
+ };
8403
+ const grabViaPointer = (grabEvent, options) => {
8404
+ if (grabEvent.type === "pointerdown") {
8405
+ return initDragByPointer(grabEvent, options, ({
8406
+ onMove,
8407
+ onRelease,
8408
+ gestureInfo
8409
+ }) => {
8410
+ // Captured on something that will still be there at the end of the
8411
+ // gesture: the browser releases the capture when its element leaves the
8412
+ // document, and a gesture whose own effect replaces the DOM under the
8413
+ // finger would lose the pointer at its first move. Callers whose target
8414
+ // is stable have nothing to say and keep it.
8415
+ const target = options?.pointerCaptureElement || grabEvent.target;
8416
+ target.setPointerCapture(grabEvent.pointerId);
8417
+ /*
8418
+ * A touchmove left alone is the browser deciding the touch belongs to
8419
+ * it: it takes it to scroll with, and a touch it has taken is a pointer
8420
+ * stream it CANCELS — the gesture dies mid-move, the finger is still
8421
+ * down, and nothing reads it anymore.
8422
+ *
8423
+ * Refused only once the gesture is established (a `touch-action: none`
8424
+ * would take the touch from everyone who merely brushes past the
8425
+ * element), but LISTENED FOR from the grab: whether a touchmove can be
8426
+ * refused at all is decided when the touch begins, from the listeners
8427
+ * present at that moment. Registered later, the listener is handed
8428
+ * events that are already `cancelable: false` — refusing them does
8429
+ * nothing, and the reason is invisible in the code that refuses.
8430
+ *
8431
+ * On the window in capture AND on the grabbed element: a touch keeps
8432
+ * being dispatched at the node it started on, and a gesture may take
8433
+ * that node out of the document (a page that travels navigates) — from
8434
+ * then on the event never passes through the window on its way
8435
+ * anywhere.
8436
+ */
8437
+ const preventTouchScroll = touchMoveEvent => {
8438
+ if (gestureInfo.started && touchMoveEvent.cancelable) {
8439
+ touchMoveEvent.preventDefault();
8440
+ }
8441
+ };
8442
+ const grabTarget = grabEvent.target;
8443
+ window.addEventListener("touchmove", preventTouchScroll, {
8444
+ passive: false,
8445
+ capture: true
8446
+ });
8447
+ grabTarget.addEventListener("touchmove", preventTouchScroll, {
8448
+ passive: false
8449
+ });
8450
+ // Only OUR capture ending means this gesture is over:
8451
+ // lostpointercapture bubbles, so a descendant giving up its own capture
8452
+ // walks straight into this listener. That is not a rare shape — it is
8453
+ // exactly what happens when a gesture hands over to another one (a
8454
+ // press that becomes a drag releases its intermediate gesture, held on
8455
+ // the pressed element, while the real one is being held on a container
8456
+ // above it), and taken as our own it kills the new gesture one
8457
+ // millisecond after it started.
8458
+ const onCaptureLost = pointerEvent => {
8459
+ if (pointerEvent.target !== target) {
8460
+ return;
8461
+ }
8462
+ onRelease(pointerEvent);
8463
+ };
8464
+ target.addEventListener("lostpointercapture", onCaptureLost);
8465
+ target.addEventListener("pointercancel", onRelease);
8466
+ target.addEventListener("pointermove", onMove);
8467
+ target.addEventListener("pointerup", onRelease);
8468
+ // The end of the pointer is also listened for on the window, because
8469
+ // the end is the one event a gesture cannot afford to miss and the
8470
+ // element it is captured on is not always on its way: a pointer can
8471
+ // be delivered somewhere else entirely (a browser view transition
8472
+ // sends presses to the document root), and a cancel dispatched there
8473
+ // never passes through this element. Missed, the gesture never ends —
8474
+ // whatever it was holding stays held.
8475
+ let released = false;
8476
+ const onPointerEnd = pointerEvent => {
8477
+ if (pointerEvent.pointerId !== grabEvent.pointerId || released) {
8478
+ return;
8479
+ }
8480
+ released = true;
8481
+ onRelease(pointerEvent);
8482
+ };
8483
+ window.addEventListener("pointerup", onPointerEnd, true);
8484
+ window.addEventListener("pointercancel", onPointerEnd, true);
8485
+ return () => {
8486
+ // Listeners first, capture last: giving the pointer back is the
8487
+ // one thing here that can throw, and a gesture that fails to clean
8488
+ // up half way is worse than one that never cleaned up at all — its
8489
+ // listeners stay on the element and answer the NEXT gesture, from
8490
+ // a gesture whose pointer is long gone.
8491
+ window.removeEventListener("touchmove", preventTouchScroll, {
8492
+ capture: true
8493
+ });
8494
+ grabTarget.removeEventListener("touchmove", preventTouchScroll);
8495
+ target.removeEventListener("lostpointercapture", onCaptureLost);
8496
+ target.removeEventListener("pointercancel", onRelease);
8497
+ target.removeEventListener("pointermove", onMove);
8498
+ target.removeEventListener("pointerup", onRelease);
8499
+ window.removeEventListener("pointerup", onPointerEnd, true);
8500
+ window.removeEventListener("pointercancel", onPointerEnd, true);
8501
+ // Asked for only while there is something to give back: a pointer
8502
+ // that is up no longer exists, the browser has already dropped the
8503
+ // capture with it, and asking again throws ("No active pointer with
8504
+ // the given id is found") — on the most ordinary release there is.
8505
+ if (target.hasPointerCapture(grabEvent.pointerId)) {
8506
+ target.releasePointerCapture(grabEvent.pointerId);
8507
+ }
8508
+ };
8509
+ });
8510
+ }
8511
+ if (grabEvent.type === "mousedown") {
8512
+ console.warn(`Received "mousedown" event, "pointerdown" events are recommended to perform drag gestures.`);
8513
+ return initDragByPointer(grabEvent, options, ({
8514
+ onMove,
8515
+ onRelease
8516
+ }) => {
8517
+ const onPointerUp = pointerEvent => {
8518
+ // <button disabled> for example does not emit mouseup if we release mouse over it
8519
+ // -> we add "pointerup" to catch mouseup occuring on disabled element
8520
+ if (pointerEvent.pointerType === "mouse") {
8521
+ onRelease(pointerEvent);
8522
+ }
8523
+ };
8524
+ document.addEventListener("mousemove", onMove);
8525
+ document.addEventListener("mouseup", onRelease);
8526
+ document.addEventListener("pointerup", onPointerUp);
8527
+ return () => {
8528
+ document.removeEventListener("mousemove", onMove);
8529
+ document.removeEventListener("mouseup", onRelease);
8530
+ document.removeEventListener("pointerup", onPointerUp);
8531
+ };
8532
+ });
8533
+ }
8534
+ throw new Error(`Unsupported "${grabEvent.type}" evenet passed to grabViaPointer. "pointerdown" was expected.`);
8535
+ };
8536
+ dragGestureController.grabViaPointer = grabViaPointer;
8537
+ return dragGestureController;
8538
+ };
8350
8539
 
8351
- return () => {
8352
- cleanup();
8540
+ // Only the primary button drags: a right click (or any secondary button) opens
8541
+ // a context menu, it never grabs anything.
8542
+ const isPrimaryButtonEvent = event => event.button === undefined || event.button === 0;
8543
+
8544
+ /*
8545
+ * Speed over the last VELOCITY_WINDOW_MS rather than between the last two
8546
+ * events: pointer events arrive irregularly, and the last one before a release
8547
+ * often repeats the previous coordinates — measured on that pair alone, every
8548
+ * throw would end at zero.
8549
+ * A pointer held still keeps producing samples at the same place, so the window
8550
+ * empties itself of movement and the speed falls back to zero on its own: put
8551
+ * down slowly is not thrown.
8552
+ */
8553
+ const VELOCITY_WINDOW_MS = 100;
8554
+ const createVelocityMeter = (grabX, grabY) => {
8555
+ const samples = [{
8556
+ time: performance.now(),
8557
+ x: grabX,
8558
+ y: grabY
8559
+ }];
8560
+ const measureVelocity = (x, y) => {
8561
+ const time = performance.now();
8562
+ samples.push({
8563
+ time,
8564
+ x,
8565
+ y
8566
+ });
8567
+ while (samples.length > 2 && time - samples[1].time > VELOCITY_WINDOW_MS) {
8568
+ samples.shift();
8569
+ }
8570
+ const oldestSample = samples[0];
8571
+ const elapsed = time - oldestSample.time;
8572
+ if (elapsed === 0) {
8573
+ return [0, 0];
8574
+ }
8575
+ return [(x - oldestSample.x) / elapsed, (y - oldestSample.y) / elapsed];
8353
8576
  };
8577
+ return measureVelocity;
8578
+ };
8579
+ const definePropertyAsReadOnly = (object, propertyName) => {
8580
+ Object.defineProperty(object, propertyName, {
8581
+ writable: false,
8582
+ value: object[propertyName]
8583
+ });
8354
8584
  };
8355
8585
 
8356
8586
  installImportMetaCssBuild(import.meta);/**
8357
- * Drag Gesture System
8587
+ * When a press becomes a drag.
8358
8588
  *
8359
- * TODO: rename moveX/moveY en juste x/y
8360
- * puisque move c'est perturbant sachant que c'est drag + scroll
8361
- * et que drag c'est juste la partie mouvement de la souris
8589
+ * A pointer going down on a draggable element is ambiguous — it may be a click,
8590
+ * a text selection, a scroll, or a drag and starting the gesture right away
8591
+ * would steal all the others. This module owns the wait that resolves the
8592
+ * ambiguity, and only then hands over to the real gesture.
8362
8593
  *
8363
- * donc juste x/y ca seras surement mieux
8594
+ * There is one gesture, with a trigger per pointer:
8595
+ * - a dedicated handle ([data-drag-handle]) says it outright: drag on contact
8596
+ * - a mouse resolves it by distance — a mouse scrolls with its wheel, so travel
8597
+ * can only mean drag
8598
+ * - a finger resolves it by time — travel is exactly what a scroll looks like,
8599
+ * so the only unambiguous signal left is a finger that does NOT move
8364
8600
  *
8601
+ * Whichever trigger fired, it has established the intent: the gesture then
8602
+ * starts at the first pixel, without a second threshold to cross.
8365
8603
  */
8604
+
8605
+ /* At module scope, and on the markers rather than on the pressed element: both
8606
+ rules below have to be true BEFORE the finger lands — a stylesheet, never a
8607
+ line of JS in the pointerdown.
8608
+
8609
+ -webkit-touch-callout: iOS shows its callout (Copy / Look Up) and selects the
8610
+ text under the finger on a long press, and does not always route that through
8611
+ an event that can be refused — see preventContextMenu below for the half that
8612
+ is an event.
8613
+
8614
+ touch-action: a touchmove can only be refused if the region was out of the
8615
+ compositor's fast path when the touch BEGAN (see preventTouchScroll in
8616
+ drag_gesture.js, which does the refusing). Left at `auto`, Chrome has already
8617
+ decided the touch is its own by the time a long press turns into a grab, and
8618
+ every preventDefault from then on is a "Unable to preventDefault inside
8619
+ passive event listener" intervention — on Android, a scroll that runs away
8620
+ with the object. Any explicit value other than `auto` is enough: `pan-y` still
8621
+ lets the page scroll and still makes the refusal effective. */
8366
8622
  const css$4 = /* css */`
8367
- .navi_drag_gesture_backdrop {
8368
- position: fixed;
8369
- inset: 0;
8370
- user-select: none;
8623
+ [data-drag-handle],
8624
+ [data-drag-source] {
8625
+ -webkit-touch-callout: none;
8626
+ }
8627
+ [data-drag-handle] {
8628
+ /* A dedicated handle has nothing to share: it takes the gesture on contact. */
8629
+ touch-action: none;
8630
+ }
8631
+ [data-drag-source] {
8632
+ /* A source taken by long press must let the scroll through until the grab —
8633
+ which is exactly what the long press is there to tell apart. Zoom has
8634
+ nothing to do with the gesture and nobody should lose it by resting a
8635
+ finger on a word. */
8636
+ touch-action: pan-y pinch-zoom;
8637
+ }
8638
+ [data-drag-source="x"] {
8639
+ /* The axis is the one thing the caller has to say, being the only one who
8640
+ knows which way what surrounds the source scrolls. */
8641
+ touch-action: pan-x pinch-zoom;
8642
+ }
8643
+ [data-drag-ignore] {
8644
+ -webkit-touch-callout: default;
8645
+ touch-action: auto;
8371
8646
  }
8372
8647
  `;
8373
- const createDragGestureController = (options = {}) => {
8648
+ import.meta.css = [css$4, "@jsenv/dom/src/interaction/drag/drag_after_intent.js"];
8649
+
8650
+ /**
8651
+ * Waits for the user to mean it, then starts a drag gesture.
8652
+ *
8653
+ * @param {PointerEvent} grabEvent
8654
+ * The `pointerdown` event that may become a drag.
8655
+ * @param {function} dragGestureInitializer
8656
+ * Called once the intent is established; must create and return the real drag
8657
+ * gesture (typically via `grabViaPointer(grabEvent)`). Returning a falsy value
8658
+ * aborts the gesture.
8659
+ * @param {object} [options]
8660
+ * @param {number} [options.threshold=5]
8661
+ * Distance (px) the pointer must travel to start a drag, when the trigger is
8662
+ * distance-based.
8663
+ * @param {boolean|"if-touch"} [options.longPress="if-touch"]
8664
+ * Which pointers start a drag by holding still instead of by travelling.
8665
+ * @param {number} [options.longPressDelay=400]
8666
+ * How long (ms) the pointer must stay down. Kept under the system context-menu
8667
+ * delay so the object is picked up before the menu would have opened.
8668
+ * @param {number} [options.longPressSlop=8]
8669
+ * How far (px) the pointer may drift during the wait before the press is
8670
+ * abandoned — beyond it, the finger is scrolling, not holding.
8671
+ * @param {function} [options.onPressStart]
8672
+ * The pointer went down and the wait began (a cue that the press counts).
8673
+ * @param {function} [options.onPressCancel]
8674
+ * The pointer moved or lifted before the wait was over.
8675
+ * @param {function} [options.onPress]
8676
+ * The wait completed and the object is now held (haptics, scale…).
8677
+ */
8678
+ const dragAfterIntent = (grabEvent, dragGestureInitializer, {
8679
+ threshold = 5,
8680
+ longPress = "if-touch",
8681
+ longPressDelay = 400,
8682
+ longPressSlop = 8,
8683
+ onPressStart,
8684
+ onPressCancel,
8685
+ onPress
8686
+ } = {}) => {
8687
+ if (!isPrimaryButtonEvent(grabEvent)) {
8688
+ return;
8689
+ }
8690
+ const target = grabEvent.target;
8691
+ const isDedicatedHandle = target.closest && target.closest("[data-drag-handle]");
8692
+ if (isDedicatedHandle) {
8693
+ startDragGesture(dragGestureInitializer);
8694
+ return;
8695
+ }
8696
+ const startsOnLongPress = longPress === true || longPress === "if-touch" && grabEvent.pointerType === "touch";
8697
+ if (startsOnLongPress) {
8698
+ dragAfterLongPress(grabEvent, dragGestureInitializer, {
8699
+ longPressDelay,
8700
+ longPressSlop,
8701
+ onPressStart,
8702
+ onPressCancel,
8703
+ onPress
8704
+ });
8705
+ return;
8706
+ }
8707
+ dragAfterDistance(grabEvent, dragGestureInitializer, threshold);
8708
+ };
8709
+ const startDragGesture = (dragGestureInitializer, catchUpEvent) => {
8710
+ const dragGesture = dragGestureInitializer();
8711
+ if (!dragGesture) {
8712
+ return null;
8713
+ }
8714
+ // The wait is what established the intent; a distance threshold on top of it
8715
+ // would ask the user to prove the same thing twice.
8716
+ dragGesture.start();
8717
+ if (catchUpEvent) {
8718
+ dragGesture.dragViaPointer(catchUpEvent);
8719
+ }
8720
+ return dragGesture;
8721
+ };
8722
+ const dragAfterDistance = (grabEvent, dragGestureInitializer, threshold) => {
8723
+ const significantDragGestureController = createDragGestureController({
8724
+ threshold,
8725
+ // allow interaction for this intermediate gesture:
8726
+ // user should still be able to scroll or interact with the document
8727
+ // only once the gesture is significant we take control
8728
+ documentInteractions: "manual",
8729
+ onDragStart: gestureInfo => {
8730
+ significantDragGesture.release(); // kill that gesture
8731
+ startDragGesture(dragGestureInitializer, gestureInfo.dragEvent);
8732
+ }
8733
+ });
8734
+ const significantDragGesture = significantDragGestureController.grabViaPointer(grabEvent, {
8735
+ element: grabEvent.target
8736
+ });
8737
+ };
8738
+ const dragAfterLongPress = (grabEvent, dragGestureInitializer, {
8739
+ longPressDelay,
8740
+ longPressSlop,
8741
+ onPressStart,
8742
+ onPressCancel,
8743
+ onPress
8744
+ }) => {
8374
8745
  const {
8375
- name,
8376
- onGrab,
8377
- onDragStart,
8378
- onDrag,
8379
- onRelease,
8380
- threshold = 5,
8381
- direction: defaultDirection = {
8382
- x: true,
8383
- y: true
8384
- },
8385
- documentInteractions = "auto",
8386
- backdrop = true,
8387
- backdropZIndex = 999999
8388
- } = options;
8389
- const dragGestureController = {
8390
- grab: null,
8391
- gravViaPointer: null
8746
+ pointerId,
8747
+ clientX,
8748
+ clientY
8749
+ } = grabEvent;
8750
+ const pressCleanupCallbacks = [];
8751
+ const endPress = () => {
8752
+ for (const pressCleanupCallback of pressCleanupCallbacks) {
8753
+ pressCleanupCallback();
8754
+ }
8755
+ pressCleanupCallbacks.length = 0;
8392
8756
  };
8393
- const grab = ({
8394
- element,
8395
- direction = defaultDirection,
8396
- event = new CustomEvent("programmatic"),
8397
- grabX = 0,
8398
- grabY = 0,
8399
- cursor = "grabbing",
8400
- scrollContainer = document.documentElement,
8401
- layoutScrollableLeft: scrollableLeftAtGrab = 0,
8402
- layoutScrollableTop: scrollableTopAtGrab = 0
8403
- } = {}) => {
8404
- if (!element) {
8405
- throw new Error("element is required");
8757
+
8758
+ /*
8759
+ * A press held long enough IS the system's context-menu gesture: Android opens
8760
+ * its menu around 500ms, iOS its callout — both a tenth of a second after the
8761
+ * object has been picked up, landing on top of something the finger is already
8762
+ * carrying.
8763
+ * The listener goes on window, in capture: once the gesture runs, the drag
8764
+ * backdrop covers the page, so the contextmenu event is aimed at the backdrop
8765
+ * and never reaches the element being dragged.
8766
+ * It is removed on release — a right click with a mouse remains a right click.
8767
+ */
8768
+ const preventContextMenu = contextMenuEvent => {
8769
+ contextMenuEvent.preventDefault();
8770
+ };
8771
+ window.addEventListener("contextmenu", preventContextMenu, true);
8772
+ pressCleanupCallbacks.push(() => {
8773
+ window.removeEventListener("contextmenu", preventContextMenu, true);
8774
+ });
8775
+ const countdownCleanupCallbacks = [];
8776
+ const endCountdown = () => {
8777
+ for (const countdownCleanupCallback of countdownCleanupCallbacks) {
8778
+ countdownCleanupCallback();
8406
8779
  }
8407
- if (!direction.x && !direction.y) {
8408
- return null;
8780
+ countdownCleanupCallbacks.length = 0;
8781
+ };
8782
+ const timeout = setTimeout(() => {
8783
+ endCountdown();
8784
+ onPress?.(grabEvent);
8785
+ // Scrolling is taken away by the gesture itself, from the moment it starts
8786
+ // (see markAsStarted in drag_gesture.js) — one place refuses the touchmove,
8787
+ // for every way a drag can begin.
8788
+ const dragGesture = startDragGesture(dragGestureInitializer);
8789
+ if (!dragGesture) {
8790
+ endPress();
8791
+ return;
8409
8792
  }
8410
- const [publishBeforeDrag, addBeforeDragCallback] = createPubSub();
8411
- const [publishDrag, addDragCallback] = createPubSub();
8412
- const [publishRelease, addReleaseCallback] = createPubSub();
8413
- if (onDrag) {
8414
- addDragCallback(onDrag);
8793
+ dragGesture.addReleaseCallback(endPress);
8794
+ }, longPressDelay);
8795
+ countdownCleanupCallbacks.push(() => {
8796
+ clearTimeout(timeout);
8797
+ });
8798
+ const cancelPress = pointerEvent => {
8799
+ endCountdown();
8800
+ endPress();
8801
+ onPressCancel?.(pointerEvent);
8802
+ };
8803
+ const onPointerMove = pointerMoveEvent => {
8804
+ if (pointerMoveEvent.pointerId !== pointerId) {
8805
+ return;
8415
8806
  }
8416
- if (onRelease) {
8417
- addReleaseCallback(onRelease);
8807
+ const xDrift = Math.abs(pointerMoveEvent.clientX - clientX);
8808
+ const yDrift = Math.abs(pointerMoveEvent.clientY - clientY);
8809
+ if (xDrift < longPressSlop && yDrift < longPressSlop) {
8810
+ return;
8418
8811
  }
8419
- const scrollLeftAtGrab = scrollContainer.scrollLeft;
8420
- const scrollTopAtGrab = scrollContainer.scrollTop;
8421
- const leftAtGrab = scrollLeftAtGrab + scrollableLeftAtGrab;
8422
- const topAtGrab = scrollTopAtGrab + scrollableTopAtGrab;
8423
- const createLayout = (x, y) => {
8424
- const {
8425
- scrollLeft,
8426
- scrollTop
8427
- } = scrollContainer;
8428
- const left = scrollableLeftAtGrab + x;
8429
- const top = scrollableTopAtGrab + y;
8430
- const scrollableLeft = left - scrollLeft;
8431
- const scrollableTop = top - scrollTop;
8432
- const layoutProps = {
8433
- // Raw input coordinates (dragX - grabX + scrollContainer.scrollLeft)
8434
- x,
8435
- y,
8436
- // container scrolls when layout is created
8437
- scrollLeft,
8438
- scrollTop,
8439
- // Position relative to container excluding scrolls
8440
- scrollableLeft,
8441
- scrollableTop,
8442
- // Position relative to container including scrolls
8443
- left,
8444
- top,
8445
- // Delta since grab (number representing how much we dragged)
8446
- xDelta: left - leftAtGrab,
8447
- yDelta: top - topAtGrab
8448
- };
8449
- return layoutProps;
8450
- };
8451
- const grabLayout = createLayout(grabX + scrollContainer.scrollLeft, grabY + scrollContainer.scrollTop);
8452
- const gestureInfo = {
8453
- name,
8454
- direction,
8455
- started: !threshold,
8456
- status: "grabbed",
8457
- element,
8458
- scrollContainer,
8459
- grabX,
8460
- // x grab coordinate (excluding scroll)
8461
- grabY,
8462
- // y grab coordinate (excluding scroll)
8463
- grabLayout,
8464
- leftAtGrab,
8465
- topAtGrab,
8466
- dragX: grabX,
8467
- // coordinate of the last drag (excluding scroll of the scrollContainer)
8468
- dragY: grabY,
8469
- // coordinate of the last drag (excluding scroll of the scrollContainer)
8470
- layout: grabLayout,
8471
- isGoingUp: undefined,
8472
- isGoingDown: undefined,
8473
- isGoingLeft: undefined,
8474
- isGoingRight: undefined,
8475
- intentGoingUp: false,
8476
- intentGoingDown: false,
8477
- intentGoingLeft: false,
8478
- intentGoingRight: false,
8479
- // metadata about interaction sources
8480
- grabEvent: event,
8481
- dragEvent: null,
8482
- releaseEvent: null
8483
- };
8484
- definePropertyAsReadOnly(gestureInfo, "name");
8485
- definePropertyAsReadOnly(gestureInfo, "direction");
8486
- definePropertyAsReadOnly(gestureInfo, "scrollContainer");
8487
- definePropertyAsReadOnly(gestureInfo, "grabX");
8488
- definePropertyAsReadOnly(gestureInfo, "grabY");
8489
- definePropertyAsReadOnly(gestureInfo, "grabLayout");
8490
- definePropertyAsReadOnly(gestureInfo, "leftAtGrab");
8491
- definePropertyAsReadOnly(gestureInfo, "topAtGrab");
8492
- definePropertyAsReadOnly(gestureInfo, "grabEvent");
8493
- document_interactions: {
8494
- if (documentInteractions === "manual") {
8495
- break document_interactions;
8496
- }
8497
- /*
8498
- GOAL: Take control of document-level interactions during drag gestures
8499
-
8500
- WHY: During drag operations, we need to prevent conflicting user interactions that would:
8501
- 1. Interfere with the drag gesture (competing pointer events, focus changes)
8502
- 2. Break the visual feedback (inconsistent cursors, hover states)
8503
- 3. Cause unwanted scrolling (keyboard shortcuts, wheel events in restricted directions)
8504
- 4. Create accessibility issues (focus jumping, screen reader confusion)
8505
- STRATEGY: Create a controlled interaction environment by:
8506
- 1. VISUAL CONTROL: Use a backdrop to unify cursor appearance and block pointer events
8507
- 2. INTERACTION ISOLATION: Make non-dragged elements inert to prevent interference
8508
- 3. FOCUS MANAGEMENT: Control focus location and prevent focus changes during drag
8509
- 4. SELECTIVE SCROLLING: Allow scrolling only in directions supported by the drag gesture
8510
- IMPLEMENTATION:
8511
- */
8512
-
8513
- // 1. INTERACTION ISOLATION: Make everything except the dragged element inert
8514
- // This prevents keyboard events, pointer interactions, and screen reader navigation
8515
- // on non-relevant elements during the drag operation
8516
- const cleanupInert = isolateInteractions([element, ...Array.from(document.querySelectorAll("[data-droppable]"))]);
8517
- addReleaseCallback(() => {
8518
- cleanupInert();
8519
- });
8520
-
8521
- // 2. VISUAL CONTROL: Backdrop for consistent cursor and pointer event blocking
8522
- if (backdrop) {
8523
- import.meta.css = [css$4, "@jsenv/dom/src/interaction/drag/drag_gesture.js"];
8524
- const backdropElement = document.createElement("div");
8525
- backdropElement.className = "navi_drag_gesture_backdrop";
8526
- backdropElement.ariaHidden = "true";
8527
- backdropElement.setAttribute("data-backdrop", "");
8528
- backdropElement.style.zIndex = backdropZIndex;
8529
- backdropElement.style.cursor = cursor;
8530
-
8531
- // Handle wheel events on backdrop for directionally-constrained drag gestures
8532
- // (e.g., table column resize should only allow horizontal scrolling)
8533
- if (!direction.x || !direction.y) {
8534
- backdropElement.onwheel = e => {
8535
- e.preventDefault();
8536
- const scrollX = direction.x ? e.deltaX : 0;
8537
- const scrollY = direction.y ? e.deltaY : 0;
8538
- scrollContainer.scrollBy({
8539
- left: scrollX,
8540
- top: scrollY,
8541
- behavior: "auto"
8542
- });
8543
- };
8544
- }
8545
- document.body.appendChild(backdropElement);
8546
- addReleaseCallback(() => {
8547
- backdropElement.remove();
8548
- });
8549
- }
8550
-
8551
- // 3. FOCUS MANAGEMENT: Control and stabilize focus during drag
8552
- const {
8553
- activeElement
8554
- } = document;
8555
- const focusableElement = findFocusable(element);
8556
- // Focus the dragged element (or document.body as fallback) to establish clear focus context
8557
- // This also ensure any keydown event listened by the currently focused element
8558
- // won't be available during drag
8559
- const elementToFocus = focusableElement || document.body;
8560
- elementToFocus.focus({
8561
- preventScroll: true
8562
- });
8563
- addReleaseCallback(() => {
8564
- // Restore original focus on release
8565
- activeElement.focus({
8566
- preventScroll: true
8567
- });
8568
- });
8569
- // Prevent Tab navigation entirely (focus should stay stable)
8570
- const onkeydown = e => {
8571
- if (e.key === "Tab") {
8572
- e.preventDefault();
8573
- return;
8574
- }
8575
- };
8576
- document.addEventListener("keydown", onkeydown);
8577
- addReleaseCallback(() => {
8578
- document.removeEventListener("keydown", onkeydown);
8579
- });
8812
+ // The finger is going somewhere: it is scrolling the page, or running down
8813
+ // the list. Letting the countdown survive would unhook an object in passing.
8814
+ cancelPress(pointerMoveEvent);
8815
+ };
8816
+ const onPointerEnd = pointerEndEvent => {
8817
+ if (pointerEndEvent.pointerId !== pointerId) {
8818
+ return;
8819
+ }
8820
+ cancelPress(pointerEndEvent);
8821
+ };
8822
+ // On window rather than on the element: the finger can leave it, and the
8823
+ // element itself can be taken out of the document while the press is waiting.
8824
+ window.addEventListener("pointermove", onPointerMove);
8825
+ window.addEventListener("pointerup", onPointerEnd);
8826
+ window.addEventListener("pointercancel", onPointerEnd);
8827
+ countdownCleanupCallbacks.push(() => {
8828
+ window.removeEventListener("pointermove", onPointerMove);
8829
+ window.removeEventListener("pointerup", onPointerEnd);
8830
+ window.removeEventListener("pointercancel", onPointerEnd);
8831
+ });
8832
+ onPressStart?.(grabEvent);
8833
+ };
8580
8834
 
8581
- // 4. SELECTIVE SCROLLING: Allow keyboard scrolling only in supported directions
8582
- {
8583
- const onDocumentKeydown = keyboardEvent => {
8584
- // Vertical scrolling keys - prevent if vertical movement not supported
8585
- if (keyboardEvent.key === "ArrowUp" || keyboardEvent.key === "ArrowDown" || keyboardEvent.key === " " || keyboardEvent.key === "PageUp" || keyboardEvent.key === "PageDown" || keyboardEvent.key === "Home" || keyboardEvent.key === "End") {
8586
- if (!direction.y) {
8587
- keyboardEvent.preventDefault();
8588
- }
8589
- return;
8590
- }
8591
- // Horizontal scrolling keys - prevent if horizontal movement not supported
8592
- if (keyboardEvent.key === "ArrowLeft" || keyboardEvent.key === "ArrowRight") {
8593
- if (!direction.x) {
8594
- keyboardEvent.preventDefault();
8595
- }
8596
- return;
8597
- }
8598
- };
8599
- document.addEventListener("keydown", onDocumentKeydown);
8600
- addReleaseCallback(() => {
8601
- document.removeEventListener("keydown", onDocumentKeydown);
8602
- });
8603
- }
8835
+ /**
8836
+ * The element `element` is genuinely `position: absolute`/`fixed` relative
8837
+ * to: its own nearest positioned ancestor (walking up the DOM tree), or
8838
+ * `document.documentElement` (the viewport) if none is found.
8839
+ *
8840
+ * Also aware of `element` itself being promoted to the top layer: a
8841
+ * `<dialog>` actually shown modally (`showModal()`, matches `:modal` — a
8842
+ * `.show()`'d, non-modal dialog does NOT match and is positioned like any
8843
+ * other in-flow element instead, walked up normally below), or *any*
8844
+ * `[popover]` element, always uses the initial containing block (the
8845
+ * viewport) regardless of its own `position` or DOM ancestry — walking up
8846
+ * its own parent chain (what the rest of this function does) would give
8847
+ * the wrong answer for these two specifically, since their real DOM
8848
+ * position becomes irrelevant to their own containing block the moment
8849
+ * they're actually promoted. Checked via the `popover` attribute itself,
8850
+ * not the live `:popover-open` state — unlike `<dialog>`, a `[popover]`
8851
+ * element has no "local" mode: it's always top-layer-bound once shown,
8852
+ * regardless of whether it happens to be open right this moment, so the
8853
+ * static attribute alone is enough (and correct even when called just
8854
+ * before `showPopover()` actually runs, when `:popover-open` isn't true
8855
+ * yet).
8856
+ *
8857
+ * `document.documentElement` (not `document.body`, not `null`) is this
8858
+ * function's own "no real container — use the viewport" sentinel:
8859
+ * `documentElement` is the actual initial containing block, so the walk
8860
+ * below stops there without testing its own `position` (there's nothing
8861
+ * beyond it to fall back to anyway) — unlike the previous version of this
8862
+ * function, which stopped one level too early, at `document.body`, without
8863
+ * ever testing *its* `position` either (a `position: relative` body, for
8864
+ * instance, would have been silently skipped). Returning `documentElement`
8865
+ * instead of `null` also means no special-casing is needed by callers that
8866
+ * already compare a resolved container against `document.documentElement`
8867
+ * (see e.g. visible_rect.js's own `hasRealContainer` check).
8868
+ */
8869
+ const getPositionedParent = (element) => {
8870
+ const isPromotedToTopLayer =
8871
+ (element.tagName === "DIALOG" && element.matches(":modal")) ||
8872
+ element.hasAttribute("popover");
8873
+ if (isPromotedToTopLayer) {
8874
+ return document.documentElement;
8875
+ }
8876
+ let parent = element.parentElement;
8877
+ while (parent && parent !== document.documentElement) {
8878
+ const position = window.getComputedStyle(parent).position;
8879
+ if (
8880
+ position === "relative" ||
8881
+ position === "absolute" ||
8882
+ position === "fixed"
8883
+ ) {
8884
+ return parent;
8604
8885
  }
8886
+ parent = parent.parentElement;
8887
+ }
8888
+ return document.documentElement;
8889
+ };
8605
8890
 
8606
- // Set up scroll event handling to adjust drag position when scrolling occurs
8607
- {
8608
- let isHandlingScroll = false;
8609
- const handleScroll = scrollEvent => {
8610
- if (isHandlingScroll) {
8611
- return;
8612
- }
8613
- isHandlingScroll = true;
8614
- drag(gestureInfo.dragX, gestureInfo.dragY, {
8615
- event: scrollEvent
8616
- });
8617
- isHandlingScroll = false;
8618
- };
8619
- const scrollEventReceiver = scrollContainer === document.documentElement ? document : scrollContainer;
8620
- scrollEventReceiver.addEventListener("scroll", handleScroll, {
8621
- passive: true
8622
- });
8623
- addReleaseCallback(() => {
8624
- scrollEventReceiver.removeEventListener("scroll", handleScroll, {
8625
- passive: true
8626
- });
8627
- });
8891
+ /**
8892
+ * Walks `element` and its ancestors (stopping at, but not including,
8893
+ * `document.documentElement`) looking for the first one whose *computed*
8894
+ * `position` is `fixed` — i.e. pinned to the viewport, ignoring document
8895
+ * scroll, regardless of what `element` itself is positioned relative to.
8896
+ *
8897
+ * @param {Element} element
8898
+ * @returns {[left: number, top: number] | null} The fixed ancestor's own
8899
+ * viewport-relative `getBoundingClientRect()` origin, or `null` if neither
8900
+ * `element` nor any ancestor is fixed (i.e. `element` genuinely scrolls
8901
+ * with the document).
8902
+ */
8903
+ const findSelfOrAncestorFixedPosition = (element) => {
8904
+ let current = element;
8905
+ while (true) {
8906
+ const computedStyle = window.getComputedStyle(current);
8907
+ if (computedStyle.position === "fixed") {
8908
+ const { left, top } = current.getBoundingClientRect();
8909
+ return [left, top];
8628
8910
  }
8629
- const determineDragData = ({
8630
- dragX,
8631
- dragY,
8632
- dragEvent,
8633
- isRelease = false
8634
- }) => {
8635
- // === ÉTAT INITIAL (au moment du grab) ===
8636
- const {
8637
- grabX,
8638
- grabY,
8639
- grabLayout
8640
- } = gestureInfo;
8641
- // === CE QUI EST DEMANDÉ (où on veut aller) ===
8642
- // Calcul de la direction basé sur le mouvement précédent
8643
- // (ne tient pas compte du mouvement final une fois les contraintes appliquées)
8644
- // (ici on veut connaitre l'intention)
8645
- // on va utiliser cela pour savoir vers où on scroll si nécéssaire par ex
8646
- const currentDragX = gestureInfo.dragX;
8647
- const currentDragY = gestureInfo.dragY;
8648
- const isGoingLeft = dragX < currentDragX;
8649
- const isGoingRight = dragX > currentDragX;
8650
- const isGoingUp = dragY < currentDragY;
8651
- const isGoingDown = dragY > currentDragY;
8652
- const layoutXRequested = direction.x ? scrollContainer.scrollLeft + (dragX - grabX) : grabLayout.scrollLeft;
8653
- const layoutYRequested = direction.y ? scrollContainer.scrollTop + (dragY - grabY) : grabLayout.scrollTop;
8654
- const layoutRequested = createLayout(layoutXRequested, layoutYRequested);
8655
- const currentLayout = gestureInfo.layout;
8656
- let layout;
8657
- if (layoutRequested.x === currentLayout.x && layoutRequested.y === currentLayout.y) {
8658
- layout = currentLayout;
8659
- } else {
8660
- // === APPLICATION DES CONTRAINTES ===
8661
- let layoutConstrained = layoutRequested;
8662
- const limitLayout = (left, top) => {
8663
- layoutConstrained = createLayout(left === undefined ? layoutConstrained.x : left - scrollableLeftAtGrab, top === undefined ? layoutConstrained.y : top - scrollableTopAtGrab);
8664
- };
8665
- publishBeforeDrag(layoutRequested, currentLayout, limitLayout, {
8666
- dragEvent,
8667
- isRelease
8668
- });
8669
- // === ÉTAT FINAL ===
8670
- layout = layoutConstrained;
8671
- }
8672
- const dragData = {
8673
- dragX,
8674
- dragY,
8675
- layout,
8676
- isGoingLeft,
8677
- isGoingRight,
8678
- isGoingUp,
8679
- isGoingDown,
8680
- status: isRelease ? "released" : "dragging",
8681
- dragEvent: isRelease ? gestureInfo.dragEvent : dragEvent,
8682
- releaseEvent: isRelease ? dragEvent : null
8683
- };
8684
- if (isRelease) {
8685
- return dragData;
8686
- }
8687
- if (!gestureInfo.started && threshold) {
8688
- const deltaX = Math.abs(dragX - grabX);
8689
- const deltaY = Math.abs(dragY - grabY);
8690
- if (direction.x && direction.y) {
8691
- // Both directions: check both axes
8692
- if (deltaX < threshold && deltaY < threshold) {
8693
- return dragData;
8694
- }
8695
- } else if (direction.x) {
8696
- if (deltaX < threshold) {
8697
- return dragData;
8698
- }
8699
- } else if (direction.y) {
8700
- if (deltaY < threshold) {
8701
- return dragData;
8702
- }
8703
- }
8704
- dragData.started = true;
8705
- }
8706
- return dragData;
8707
- };
8708
- const drag = (dragX = gestureInfo.dragX,
8709
- // Scroll container relative X coordinate
8710
- dragY = gestureInfo.dragY,
8711
- // Scroll container relative Y coordinate
8712
- {
8713
- event = new CustomEvent("programmatic"),
8714
- isRelease = false
8715
- } = {}) => {
8716
- const dragData = determineDragData({
8717
- dragX,
8718
- dragY,
8719
- dragEvent: event,
8720
- isRelease
8721
- });
8722
- const startedPrevious = gestureInfo.started;
8723
- const layoutPrevious = gestureInfo.layout;
8724
- // previousGestureInfo = { ...gestureInfo };
8725
- Object.assign(gestureInfo, dragData);
8726
- if (gestureInfo.isGoingDown) {
8727
- gestureInfo.intentGoingDown = true;
8728
- gestureInfo.intentGoingUp = false;
8729
- } else if (gestureInfo.isGoingUp) {
8730
- gestureInfo.intentGoingUp = true;
8731
- gestureInfo.intentGoingDown = false;
8732
- }
8733
- if (gestureInfo.isGoingRight) {
8734
- gestureInfo.intentGoingRight = true;
8735
- gestureInfo.intentGoingLeft = false;
8736
- } else if (gestureInfo.isGoingLeft) {
8737
- gestureInfo.intentGoingLeft = true;
8738
- gestureInfo.intentGoingRight = false;
8739
- }
8740
- if (!startedPrevious && gestureInfo.started) {
8741
- dispatchPublicCustomEvent(element, "navi_drag_start", {
8742
- gestureInfo
8743
- });
8744
- onDragStart?.(gestureInfo);
8745
- // Suppress the click that the browser fires after pointerup following a real drag.
8746
- // The capture phase runs before any element onClick handler.
8747
- const suppressClick = clickEvent => {
8748
- clickEvent.stopPropagation();
8749
- clickEvent.preventDefault();
8750
- document.removeEventListener("click", suppressClick, {
8751
- capture: true
8752
- });
8753
- };
8754
- document.addEventListener("click", suppressClick, {
8755
- capture: true
8756
- });
8757
- addReleaseCallback(() => {
8758
- document.removeEventListener("click", suppressClick, {
8759
- capture: true
8760
- });
8761
- });
8762
- }
8763
- const someLayoutChange = gestureInfo.layout !== layoutPrevious;
8764
- dispatchPublicCustomEvent(element, "navi_drag", {
8765
- gestureInfo,
8766
- someLayoutChange
8767
- });
8768
- publishDrag(gestureInfo,
8769
- // we still publish drag event even when unchanged
8770
- // because UI might need to adjust when document scrolls
8771
- // even if nothing truly changes visually the element
8772
- // can decide to stick to the scroll for example
8773
- someLayoutChange);
8774
- };
8775
- const release = ({
8776
- event = new CustomEvent("programmatic"),
8777
- releaseX = gestureInfo.dragX,
8778
- releaseY = gestureInfo.dragY
8779
- } = {}) => {
8780
- drag(releaseX, releaseY, {
8781
- event,
8782
- isRelease: true
8783
- });
8784
- dispatchPublicCustomEvent(element, "navi_drag_release", {
8785
- gestureInfo
8786
- });
8787
- publishRelease(gestureInfo);
8911
+ current = current.parentElement;
8912
+ if (!current || current === document.documentElement) {
8913
+ break;
8914
+ }
8915
+ }
8916
+ return null;
8917
+ };
8918
+
8919
+ /**
8920
+ * Creates a coordinate system positioner for drag operations.
8921
+ *
8922
+ * PURPOSE:
8923
+ * During a drag gesture, the system tracks mouse movement as "scrollable coordinates"
8924
+ * relative to the scroll container. This function converts those coordinates into
8925
+ * the actual CSS transform values needed to visually move an element (or a separate
8926
+ * elementToMove) to follow the mouse.
8927
+ *
8928
+ * PARAMETERS:
8929
+ * - element: The element being grabbed / tracked for drag detection and auto-scroll.
8930
+ * - referenceElement: Optional. The element whose coordinate system defines the input space.
8931
+ * When provided, scrollable coords are relative to its scroll container.
8932
+ * Defaults to element itself.
8933
+ * - elementToMove: Optional. A different element to apply the transform to (e.g. a clone
8934
+ * or a table that moves as a whole when a column is dragged).
8935
+ * When provided, its offsetParent is used as the positioning context.
8936
+ *
8937
+ * THE COORDINATE PIPELINE:
8938
+ *
8939
+ * Mouse position
8940
+ * → scrollable coords (relative to referenceScrollContainer, scroll-independent)
8941
+ * → positioned coords (relative to elementToMove's offsetParent, for CSS transform)
8942
+ *
8943
+ * Two types of offsets bridge these spaces:
8944
+ *
8945
+ * 1. POSITION OFFSETS (getPositionOffsets):
8946
+ * Compensate for the fact that positionedParent and referencePositionedParent
8947
+ * may differ. For example, if `element` lives inside a <table> and `elementToMove`
8948
+ * is a full table clone, their offsetParents are different elements.
8949
+ * This offset is the spatial difference between those two positioned ancestors.
8950
+ * Called dynamically because parents can move (e.g. overlay elements).
8951
+ *
8952
+ * 2. SCROLL OFFSETS (getScrollOffsets):
8953
+ * Account for the scroll position of the relevant scroll container(s).
8954
+ * The math ensures that at grab time, the transform delta is zero (element
8955
+ * stays at its visual position), and subsequent mouse movement maps 1:1
8956
+ * to transform change.
8957
+ *
8958
+ * CRITICAL CASE — positionedParent outside referenceScrollContainer:
8959
+ * When elementToMove's offsetParent is NOT inside the referenceScrollContainer
8960
+ * (e.g. a clone appended to document.body while tracking an element inside
8961
+ * an overflow:auto div), the scroll offset must be FROZEN at grab time.
8962
+ * Using a live scroll value would double-move the clone during auto-scroll:
8963
+ * the scrollable coordinate decreases (element appears to move up) AND the
8964
+ * live scroll value increases — both applied to the same transform.
8965
+ * Freezing the scroll at grab time cancels this out while still correctly
8966
+ * placing the clone at the right initial position.
8967
+ *
8968
+ * KEY SCENARIOS SUPPORTED:
8969
+ * 1. Same positioned parent, same scroll container — minimal offsets
8970
+ * 2. Different positioned parents, same scroll container — position offset compensation
8971
+ * 3. Same positioned parent, different scroll containers — scroll offset bridging
8972
+ * 4. Different positioned parents, different containers — full offset compensation
8973
+ * 5. Overlay elements (data-overlay-for) — specialized offset path
8974
+ * 6. Fixed positioned elements — special scroll handling
8975
+ * 7. elementToMove outside referenceScrollContainer — frozen scroll offset at grab
8976
+ *
8977
+ * API CONTRACT:
8978
+ * Returns [scrollableLeft, scrollableTop, convertScrollablePosition] where:
8979
+ *
8980
+ * - scrollableLeft/scrollableTop:
8981
+ * The element's current position in the reference coordinate system at grab time.
8982
+ * Used as the layout starting point (layoutScrollableLeft/Top) by the gesture system.
8983
+ *
8984
+ * - convertScrollablePosition(scrollableLeft, scrollableTop):
8985
+ * Converts a scrollable coordinate (from the gesture layout) into a positioned
8986
+ * coordinate suitable for CSS transform. The gesture system computes:
8987
+ * topDelta = convertScrollablePosition(layout.scrollableTop) - topAtGrab
8988
+ * and applies that as translateY. At grab time, delta = 0. As the mouse moves,
8989
+ * delta tracks the movement exactly, regardless of scroll context differences.
8990
+ */
8991
+ const createDragElementPositioner = (
8992
+ element,
8993
+ referenceElement,
8994
+ elementToMove,
8995
+ ) => {
8996
+ let scrollableLeft;
8997
+ let scrollableTop;
8998
+ let convertScrollablePosition;
8999
+
9000
+ // getPositionedParent, not raw .offsetParent — offsetParent is null for a
9001
+ // position: fixed element, and also for one promoted to the top layer
9002
+ // (e.g. a <dialog>/[popover] being dragged by its own handle), which
9003
+ // crashes the fixed-position lookup below (findSelfOrAncestorFixedPosition
9004
+ // assumes a real starting element, not null). getPositionedParent never
9005
+ // returns null (document.documentElement instead — see its own doc).
9006
+ const positionedParent = getPositionedParent(elementToMove || element);
9007
+ const scrollContainer = getScrollContainer(element);
9008
+ const [getPositionOffsets, getScrollOffsets] = createGetOffsets({
9009
+ positionedParent,
9010
+ referencePositionedParent: referenceElement
9011
+ ? getPositionedParent(referenceElement)
9012
+ : positionedParent,
9013
+ scrollContainer,
9014
+ referenceScrollContainer: referenceElement
9015
+ ? getScrollContainer(referenceElement)
9016
+ : scrollContainer,
9017
+ });
9018
+
9019
+ {
9020
+ [scrollableLeft, scrollableTop] = getScrollablePosition(
9021
+ element,
9022
+ scrollContainer,
9023
+ );
9024
+ const [positionOffsetLeft, positionOffsetTop] = getPositionOffsets();
9025
+ scrollableLeft += positionOffsetLeft;
9026
+ scrollableTop += positionOffsetTop;
9027
+ }
9028
+ {
9029
+ convertScrollablePosition = (
9030
+ scrollableLeftToConvert,
9031
+ scrollableTopToConvert,
9032
+ ) => {
9033
+ const [positionOffsetLeft, positionOffsetTop] = getPositionOffsets();
9034
+ const [scrollOffsetLeft, scrollOffsetTop] = getScrollOffsets();
9035
+
9036
+ const positionedLeftWithoutScroll =
9037
+ scrollableLeftToConvert + positionOffsetLeft;
9038
+ const positionedTopWithoutScroll =
9039
+ scrollableTopToConvert + positionOffsetTop;
9040
+ const positionedLeft = positionedLeftWithoutScroll + scrollOffsetLeft;
9041
+ const positionedTop = positionedTopWithoutScroll + scrollOffsetTop;
9042
+
9043
+ return [positionedLeft, positionedTop];
8788
9044
  };
8789
- dispatchPublicCustomEvent(element, "navi_drag_grab", {
8790
- gestureInfo
8791
- });
8792
- onGrab?.(gestureInfo);
8793
- const dragGesture = {
8794
- gestureInfo,
8795
- addBeforeDragCallback,
8796
- addDragCallback,
8797
- addReleaseCallback,
8798
- drag,
8799
- release
9045
+ }
9046
+ return [scrollableLeft, scrollableTop, convertScrollablePosition];
9047
+ };
9048
+
9049
+ const getScrollablePosition = (element, scrollContainer) => {
9050
+ const { left: elementViewportLeft, top: elementViewportTop } =
9051
+ element.getBoundingClientRect();
9052
+ const scrollContainerIsDocument = scrollContainer === documentElement;
9053
+ if (scrollContainerIsDocument) {
9054
+ return [elementViewportLeft, elementViewportTop];
9055
+ }
9056
+ const { left: scrollContainerLeft, top: scrollContainerTop } =
9057
+ scrollContainer.getBoundingClientRect();
9058
+ const scrollableLeft = elementViewportLeft - scrollContainerLeft;
9059
+ const scrollableTop = elementViewportTop - scrollContainerTop;
9060
+
9061
+ return [scrollableLeft, scrollableTop];
9062
+ };
9063
+
9064
+ const createGetOffsets = ({
9065
+ positionedParent,
9066
+ referencePositionedParent,
9067
+ scrollContainer,
9068
+ referenceScrollContainer,
9069
+ }) => {
9070
+ const samePositionedParent = positionedParent === referencePositionedParent;
9071
+ const getScrollOffsets = createGetScrollOffsets(
9072
+ scrollContainer,
9073
+ referenceScrollContainer,
9074
+ positionedParent,
9075
+ samePositionedParent,
9076
+ );
9077
+
9078
+ if (samePositionedParent) {
9079
+ return [() => [0, 0], getScrollOffsets];
9080
+ }
9081
+
9082
+ // parents are different, oh boy let's go
9083
+ // The overlay case is problematic because the overlay adjust its position to the target dynamically
9084
+ // This creates something complex to support properly.
9085
+ // When overlay is fixed we there will never be any offset
9086
+ // When overlay is absolute there is a diff relative to the scroll
9087
+ // and eventually if the overlay is positioned differently than the other parent
9088
+ if (isOverlayOf(positionedParent, referencePositionedParent)) {
9089
+ return createGetOffsetsForOverlay(
9090
+ positionedParent,
9091
+ referencePositionedParent,
9092
+ {
9093
+ scrollContainer,
9094
+ referenceScrollContainer,
9095
+ getScrollOffsets,
9096
+ },
9097
+ );
9098
+ }
9099
+ if (isOverlayOf(referencePositionedParent, positionedParent)) {
9100
+ return createGetOffsetsForOverlay(
9101
+ referencePositionedParent,
9102
+ positionedParent,
9103
+ {
9104
+ scrollContainer,
9105
+ referenceScrollContainer,
9106
+ getScrollOffsets,
9107
+ },
9108
+ );
9109
+ }
9110
+ const scrollContainerIsDocument = scrollContainer === documentElement;
9111
+ if (scrollContainerIsDocument) {
9112
+ // Document case: getBoundingClientRect already includes document scroll effects
9113
+ // Add current scroll position to get the static offset
9114
+ const getPositionOffsetsDocumentScrolling = () => {
9115
+ const { scrollLeft: documentScrollLeft, scrollTop: documentScrollTop } =
9116
+ scrollContainer;
9117
+ const aRect = positionedParent.getBoundingClientRect();
9118
+ const bRect = referencePositionedParent.getBoundingClientRect();
9119
+ const aLeft = aRect.left;
9120
+ const aTop = aRect.top;
9121
+ const bLeft = bRect.left;
9122
+ const bTop = bRect.top;
9123
+ const aLeftDocument = documentScrollLeft + aLeft;
9124
+ const aTopDocument = documentScrollTop + aTop;
9125
+ const bLeftDocument = documentScrollLeft + bLeft;
9126
+ const bTopDocument = documentScrollTop + bTop;
9127
+ const offsetLeft = bLeftDocument - aLeftDocument;
9128
+ const offsetTop = bTopDocument - aTopDocument;
9129
+ return [offsetLeft, offsetTop];
8800
9130
  };
8801
- return dragGesture;
9131
+ return [getPositionOffsetsDocumentScrolling, getScrollOffsets];
9132
+ }
9133
+ // Custom scroll container case: account for container's position and scroll
9134
+ const getPositionOffsetsCustomScrollContainer = () => {
9135
+ const aRect = positionedParent.getBoundingClientRect();
9136
+ const bRect = referencePositionedParent.getBoundingClientRect();
9137
+ const aLeft = aRect.left;
9138
+ const aTop = aRect.top;
9139
+ const bLeft = bRect.left;
9140
+ const bTop = bRect.top;
9141
+
9142
+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
9143
+ const offsetLeft =
9144
+ bLeft - aLeft + scrollContainer.scrollLeft - scrollContainerRect.left;
9145
+ const offsetTop =
9146
+ bTop - aTop + scrollContainer.scrollTop - scrollContainerRect.top;
9147
+ return [offsetLeft, offsetTop];
8802
9148
  };
8803
- dragGestureController.grab = grab;
8804
- const initDragByPointer = (grabEvent, dragOptions, initializer) => {
8805
- if (!isPrimaryButtonEvent(grabEvent)) {
8806
- return null;
8807
- }
8808
- const target = grabEvent.target;
8809
- if (!target.closest) {
8810
- // target is a text node
8811
- return null;
9149
+ return [getPositionOffsetsCustomScrollContainer, getScrollOffsets];
9150
+ };
9151
+ const createGetOffsetsForOverlay = (
9152
+ overlay,
9153
+ overlayTarget,
9154
+ { scrollContainer, referenceScrollContainer, getScrollOffsets },
9155
+ ) => {
9156
+ const sameScrollContainer = scrollContainer === referenceScrollContainer;
9157
+ const scrollContainerIsDocument =
9158
+ scrollContainer === document.documentElement;
9159
+ const referenceScrollContainerIsDocument =
9160
+ referenceScrollContainer === documentElement;
9161
+
9162
+ if (getComputedStyle(overlay).position === "fixed") {
9163
+ if (referenceScrollContainerIsDocument) {
9164
+ const getPositionOffsetsFixedOverlay = () => {
9165
+ return [0, 0];
9166
+ };
9167
+ return [getPositionOffsetsFixedOverlay, getScrollOffsets];
8812
9168
  }
8813
- const mouseEventCoords = mouseEvent => {
8814
- const {
8815
- clientX,
8816
- clientY
8817
- } = mouseEvent;
8818
- return [clientX, clientY];
8819
- };
8820
- const [grabX, grabY] = mouseEventCoords(grabEvent);
8821
- const dragGesture = dragGestureController.grab({
8822
- grabX,
8823
- grabY,
8824
- event: grabEvent,
8825
- ...dragOptions
8826
- });
8827
- const dragViaPointer = dragEvent => {
8828
- const [mouseDragX, mouseDragY] = mouseEventCoords(dragEvent);
8829
- dragGesture.drag(mouseDragX, mouseDragY, {
8830
- event: dragEvent
8831
- });
8832
- };
8833
- const releaseViaPointer = mouseupEvent => {
8834
- const [mouseReleaseX, mouseReleaseY] = mouseEventCoords(mouseupEvent);
8835
- dragGesture.release({
8836
- event: mouseupEvent,
8837
- releaseX: mouseReleaseX,
8838
- releaseY: mouseReleaseY
8839
- });
9169
+ const getPositionOffsetsFixedOverlay = () => {
9170
+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
9171
+ const referenceScrollContainerRect =
9172
+ referenceScrollContainer.getBoundingClientRect();
9173
+ let offsetLeftBetweenScrollContainers =
9174
+ referenceScrollContainerRect.left - scrollContainerRect.left;
9175
+ let offsetTopBetweenScrollContainers =
9176
+ referenceScrollContainerRect.top - scrollContainerRect.top;
9177
+ if (scrollContainerIsDocument) {
9178
+ offsetLeftBetweenScrollContainers -= scrollContainer.scrollLeft;
9179
+ offsetTopBetweenScrollContainers -= scrollContainer.scrollTop;
9180
+ }
9181
+ return [
9182
+ -offsetLeftBetweenScrollContainers,
9183
+ -offsetTopBetweenScrollContainers,
9184
+ ];
8840
9185
  };
8841
- dragGesture.dragViaPointer = dragViaPointer;
8842
- dragGesture.releaseViaPointer = releaseViaPointer;
8843
- const cleanup = initializer({
8844
- onMove: dragViaPointer,
8845
- onRelease: releaseViaPointer
8846
- });
8847
- dragGesture.addReleaseCallback(() => {
8848
- cleanup();
8849
- });
8850
- return dragGesture;
9186
+ return [getPositionOffsetsFixedOverlay, getScrollOffsets];
9187
+ }
9188
+
9189
+ const getPositionOffsetsOverlay = () => {
9190
+ if (sameScrollContainer) {
9191
+ const overlayRect = overlay.getBoundingClientRect();
9192
+ const overlayTargetRect = overlayTarget.getBoundingClientRect();
9193
+ const overlayLeft = overlayRect.left;
9194
+ const overlayTop = overlayRect.top;
9195
+ let overlayTargetLeft = overlayTargetRect.left;
9196
+ let overlayTargetTop = overlayTargetRect.top;
9197
+ if (scrollContainerIsDocument) {
9198
+ overlayTargetLeft += scrollContainer.scrollLeft;
9199
+ overlayTargetTop += scrollContainer.scrollTop;
9200
+ }
9201
+ const offsetLeftBetweenTargetAndOverlay = overlayTargetLeft - overlayLeft;
9202
+ const offsetTopBetweenTargetAndOverlay = overlayTargetTop - overlayTop;
9203
+ return [
9204
+ -scrollContainer.scrollLeft + offsetLeftBetweenTargetAndOverlay,
9205
+ -scrollContainer.scrollTop + offsetTopBetweenTargetAndOverlay,
9206
+ ];
9207
+ }
9208
+
9209
+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
9210
+ const referenceScrollContainerRect =
9211
+ referenceScrollContainer.getBoundingClientRect();
9212
+ let scrollContainerLeft = scrollContainerRect.left;
9213
+ let scrollContainerTop = scrollContainerRect.top;
9214
+ let referenceScrollContainerLeft = referenceScrollContainerRect.left;
9215
+ let referenceScrollContainerTop = referenceScrollContainerRect.top;
9216
+ if (scrollContainerIsDocument) {
9217
+ scrollContainerLeft += scrollContainer.scrollLeft;
9218
+ scrollContainerTop += scrollContainer.scrollTop;
9219
+ }
9220
+ const offsetLeftBetweenScrollContainers =
9221
+ referenceScrollContainerLeft - scrollContainerLeft;
9222
+ const offsetTopBetweenScrollContainers =
9223
+ referenceScrollContainerTop - scrollContainerTop;
9224
+ return [
9225
+ -offsetLeftBetweenScrollContainers - referenceScrollContainer.scrollLeft,
9226
+ -offsetTopBetweenScrollContainers - referenceScrollContainer.scrollTop,
9227
+ ];
8851
9228
  };
8852
- const grabViaPointer = (grabEvent, options) => {
8853
- if (grabEvent.type === "pointerdown") {
8854
- return initDragByPointer(grabEvent, options, ({
8855
- onMove,
8856
- onRelease
8857
- }) => {
8858
- const target = grabEvent.target;
8859
- target.setPointerCapture(grabEvent.pointerId);
8860
- target.addEventListener("lostpointercapture", onRelease);
8861
- target.addEventListener("pointercancel", onRelease);
8862
- target.addEventListener("pointermove", onMove);
8863
- target.addEventListener("pointerup", onRelease);
8864
- return () => {
8865
- target.releasePointerCapture(grabEvent.pointerId);
8866
- target.removeEventListener("lostpointercapture", onRelease);
8867
- target.removeEventListener("pointercancel", onRelease);
8868
- target.removeEventListener("pointermove", onMove);
8869
- target.removeEventListener("pointerup", onRelease);
8870
- };
8871
- });
9229
+ const getScrollOffsetsOverlay = () => {
9230
+ if (sameScrollContainer) {
9231
+ return [scrollContainer.scrollLeft, scrollContainer.scrollTop];
8872
9232
  }
8873
- if (grabEvent.type === "mousedown") {
8874
- console.warn(`Received "mousedown" event, "pointerdown" events are recommended to perform drag gestures.`);
8875
- return initDragByPointer(grabEvent, options, ({
8876
- onMove,
8877
- onRelease
8878
- }) => {
8879
- const onPointerUp = pointerEvent => {
8880
- // <button disabled> for example does not emit mouseup if we release mouse over it
8881
- // -> we add "pointerup" to catch mouseup occuring on disabled element
8882
- if (pointerEvent.pointerType === "mouse") {
8883
- onRelease(pointerEvent);
8884
- }
8885
- };
8886
- document.addEventListener("mousemove", onMove);
8887
- document.addEventListener("mouseup", onRelease);
8888
- document.addEventListener("pointerup", onPointerUp);
8889
- return () => {
8890
- document.removeEventListener("mousemove", onMove);
8891
- document.removeEventListener("mouseup", onRelease);
8892
- document.removeEventListener("pointerup", onPointerUp);
9233
+
9234
+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
9235
+ const referenceScrollContainerRect =
9236
+ referenceScrollContainer.getBoundingClientRect();
9237
+ let offsetLeftBetweenScrollContainers =
9238
+ referenceScrollContainerRect.left - scrollContainerRect.left;
9239
+ let offsetTopBetweenScrollContainers =
9240
+ referenceScrollContainerRect.top - scrollContainerRect.top;
9241
+ if (scrollContainerIsDocument) {
9242
+ offsetLeftBetweenScrollContainers -= scrollContainer.scrollLeft;
9243
+ offsetTopBetweenScrollContainers -= scrollContainer.scrollTop;
9244
+ }
9245
+
9246
+ return [
9247
+ referenceScrollContainer.scrollLeft + offsetLeftBetweenScrollContainers,
9248
+ referenceScrollContainer.scrollTop + offsetTopBetweenScrollContainers,
9249
+ ];
9250
+ };
9251
+ return [getPositionOffsetsOverlay, getScrollOffsetsOverlay];
9252
+ };
9253
+ const isOverlayOf = (element, potentialTarget) => {
9254
+ const overlayForAttribute = element.getAttribute("data-overlay-for");
9255
+ if (!overlayForAttribute) {
9256
+ return false;
9257
+ }
9258
+ const overlayTarget = document.querySelector(`#${overlayForAttribute}`);
9259
+ if (!overlayTarget) {
9260
+ return false;
9261
+ }
9262
+ if (overlayTarget === potentialTarget) {
9263
+ return true;
9264
+ }
9265
+ const overlayTargetPositionedParent = getPositionedParent(overlayTarget);
9266
+ if (overlayTargetPositionedParent === potentialTarget) {
9267
+ return true;
9268
+ }
9269
+ return false;
9270
+ };
9271
+
9272
+ const { documentElement } =
9273
+ typeof document === "object" ? document : { documentElement: null };
9274
+
9275
+ const createGetScrollOffsets = (
9276
+ scrollContainer,
9277
+ referenceScrollContainer,
9278
+ positionedParent,
9279
+ samePositionedParent,
9280
+ ) => {
9281
+ const getGetScrollOffsetsSameContainer = () => {
9282
+ const scrollContainerIsDocument = scrollContainer === documentElement;
9283
+ // I don't really get why we have to add scrollLeft (scrollLeft at grab)
9284
+ // to properly position the element in this scenario
9285
+ // It happens since we use translateX to position the element
9286
+ // Or maybe since something else. In any case it works
9287
+ const { scrollLeft, scrollTop } = samePositionedParent
9288
+ ? { scrollLeft: 0, scrollTop: 0 }
9289
+ : referenceScrollContainer;
9290
+ if (scrollContainerIsDocument) {
9291
+ const fixedPosition = findSelfOrAncestorFixedPosition(positionedParent);
9292
+ if (fixedPosition) {
9293
+ const getScrollOffsetsFixed = () => {
9294
+ const leftScrollToAdd = scrollLeft + fixedPosition[0];
9295
+ const topScrollToAdd = scrollTop + fixedPosition[1];
9296
+ return [leftScrollToAdd, topScrollToAdd];
8893
9297
  };
8894
- });
9298
+ return getScrollOffsetsFixed;
9299
+ }
8895
9300
  }
8896
- throw new Error(`Unsupported "${grabEvent.type}" evenet passed to grabViaPointer. "pointerdown" was expected.`);
9301
+ const positionedParentIsInsideScrollContainer =
9302
+ referenceScrollContainer === documentElement ||
9303
+ referenceScrollContainer.contains(positionedParent);
9304
+ if (!positionedParentIsInsideScrollContainer) {
9305
+ // positionedParent is outside the scroll container (e.g. clone in document.body
9306
+ // while tracking an element inside a custom scroll container).
9307
+ // We must add the scroll at grab time as a frozen offset so that:
9308
+ // - initial topDelta = 0 (clone starts at correct position)
9309
+ // - auto-scroll doesn't double-move the clone (scroll changes cancel out in layout)
9310
+ const scrollLeftAtGrab = referenceScrollContainer.scrollLeft;
9311
+ const scrollTopAtGrab = referenceScrollContainer.scrollTop;
9312
+ return () => [scrollLeft + scrollLeftAtGrab, scrollTop + scrollTopAtGrab];
9313
+ }
9314
+ const getScrollOffsets = () => {
9315
+ const leftScrollToAdd = scrollLeft + referenceScrollContainer.scrollLeft;
9316
+ const topScrollToAdd = scrollTop + referenceScrollContainer.scrollTop;
9317
+ return [leftScrollToAdd, topScrollToAdd];
9318
+ };
9319
+ return getScrollOffsets;
8897
9320
  };
8898
- dragGestureController.grabViaPointer = grabViaPointer;
8899
- return dragGestureController;
8900
- };
8901
9321
 
8902
- // Only the primary button drags: a right click (or any secondary button) opens
8903
- // a context menu, it never grabs anything.
8904
- const isPrimaryButtonEvent = event => event.button === undefined || event.button === 0;
8905
- const dragAfterThreshold = (grabEvent, dragGestureInitializer, threshold) => {
8906
- if (!isPrimaryButtonEvent(grabEvent)) {
8907
- return;
8908
- }
8909
- const target = grabEvent.target;
8910
- const isDedicatedHandle = target.closest && target.closest("[data-drag-handle]");
8911
- if (isDedicatedHandle) {
8912
- // Element is dedicated to drag — skip the threshold and start immediately.
8913
- const dragGesture = dragGestureInitializer();
8914
- if (!dragGesture) {
8915
- return;
8916
- }
8917
- dragGesture.dragViaPointer(grabEvent);
8918
- return;
9322
+ const sameScrollContainer = scrollContainer === referenceScrollContainer;
9323
+ const getScrollOffsetsSameContainer = getGetScrollOffsetsSameContainer();
9324
+ if (sameScrollContainer) {
9325
+ return getScrollOffsetsSameContainer;
8919
9326
  }
8920
- const significantDragGestureController = createDragGestureController({
8921
- threshold,
8922
- // allow interaction for this intermediate gesture:
8923
- // user should still be able to scroll or interact with the document
8924
- // only once the gesture is significant we take control
8925
- documentInteractions: "manual",
8926
- onDragStart: gestureInfo => {
8927
- significantDragGesture.release(); // kill that gesture
8928
- const dragGesture = dragGestureInitializer();
8929
- dragGesture.dragViaPointer(gestureInfo.dragEvent);
8930
- }
8931
- });
8932
- const significantDragGesture = significantDragGestureController.grabViaPointer(grabEvent, {
8933
- element: grabEvent.target
8934
- });
9327
+ const getScrollOffsetsDifferentContainers = () => {
9328
+ const [scrollLeftToAdd, scrollTopToAdd] = getScrollOffsetsSameContainer();
9329
+ const rect = scrollContainer.getBoundingClientRect();
9330
+ const referenceRect = referenceScrollContainer.getBoundingClientRect();
9331
+ const leftDiff = referenceRect.left - rect.left;
9332
+ const topDiff = referenceRect.top - rect.top;
9333
+ return [scrollLeftToAdd + leftDiff, scrollTopToAdd + topDiff];
9334
+ };
9335
+ return getScrollOffsetsDifferentContainers;
8935
9336
  };
8936
- const definePropertyAsReadOnly = (object, propertyName) => {
8937
- Object.defineProperty(object, propertyName, {
8938
- writable: false,
8939
- value: object[propertyName]
8940
- });
9337
+ const getDragCoordinates = (
9338
+ element,
9339
+ scrollContainer = getScrollContainer(element),
9340
+ ) => {
9341
+ const [scrollableLeft, scrollableTop] = getScrollablePosition(
9342
+ element,
9343
+ scrollContainer,
9344
+ );
9345
+ const { scrollLeft, scrollTop } = scrollContainer;
9346
+ const leftRelativeToScrollContainer = scrollableLeft + scrollLeft;
9347
+ const topRelativeToScrollContainer = scrollableTop + scrollTop;
9348
+ return [leftRelativeToScrollContainer, topRelativeToScrollContainer];
8941
9349
  };
8942
9350
 
8943
9351
  installImportMetaCssBuild(import.meta);const css$3 = /* css */`
@@ -10258,6 +10666,11 @@ const dragStyleController = createStyleController("drag_to_move");
10258
10666
  * If omitted, `element` is translated. The translate is read from `dragStyleController`
10259
10667
  * at grab time so any pre-existing translate is accumulated rather than reset.
10260
10668
  *
10669
+ * A `transform` already on the moved element (rotate, scale…) is preserved and does
10670
+ * not disturb the movement. `rotate` and `scale` set as individual CSS properties do:
10671
+ * they apply outside `transform`, where nothing the gesture writes can reach them —
10672
+ * put those on a child element instead (a warning says so in dev).
10673
+ *
10261
10674
  * @param {object} [options]
10262
10675
  * @param {boolean} [options.stickyFrontiers=true]
10263
10676
  * Shrinks the auto-scroll area at sticky boundaries (elements with `data-sticky-left` /
@@ -10279,13 +10692,24 @@ const dragStyleController = createStyleController("drag_to_move");
10279
10692
  * Renders a visual line when the pointer deviates from the element due to constraints.
10280
10693
  * @param {boolean} [options.showDebugMarkers=false]
10281
10694
  * Renders debug markers for constraint regions.
10282
- * @param {"commit"|"cancel"|"manual"} [options.releasePositionEffect="commit"]
10695
+ * @param {"commit"|"cancel"|"cancel-animated"|"manual"} [options.releasePositionEffect="commit"]
10283
10696
  * Controls what happens to the translated position on release.
10284
10697
  * - `"commit"`: bakes the translate into inline styles so the element stays put (default).
10285
10698
  * - `"cancel"`: discards the translate so the element snaps back to its original position.
10699
+ * - `"cancel-animated"`: same, travelling back to it over `cancelAnimationDuration`.
10286
10700
  * - `"manual"`: does nothing — the caller is responsible for clearing or committing
10287
10701
  * the transform via `dragStyleController`.
10702
+ * @param {number} [options.cancelAnimationDuration=200]
10703
+ * Duration (ms) of the way back for `"cancel-animated"`.
10704
+ * @param {string} [options.cancelAnimationEasing="ease-out"]
10705
+ * Easing of the way back for `"cancel-animated"`.
10288
10706
  * @returns {object} Drag gesture controller with augmented `grab()` / `grabViaPointer()` methods.
10707
+ *
10708
+ * `gestureInfo` gains `cancelPosition()`, `commitPosition()` and
10709
+ * `cancelPositionAnimated({duration, easing})` — the last returns the `Animation`
10710
+ * playing the way back (`null` when the element was already home), so a caller
10711
+ * on `"manual"` can decide between thrown and put back, and still await the
10712
+ * landing.
10289
10713
  */
10290
10714
  const createDragToMoveGestureController = ({
10291
10715
  stickyFrontiers = true,
@@ -10296,6 +10720,8 @@ const createDragToMoveGestureController = ({
10296
10720
  showConstraintFeedbackLine = false,
10297
10721
  showDebugMarkers = false,
10298
10722
  releasePositionEffect = "commit",
10723
+ cancelAnimationDuration = 200,
10724
+ cancelAnimationEasing = "ease-out",
10299
10725
  ...options
10300
10726
  } = {}) => {
10301
10727
  const initGrabToMoveElement = (
@@ -10321,15 +10747,39 @@ const createDragToMoveGestureController = ({
10321
10747
  const cancelPosition = () => {
10322
10748
  dragStyleController.clear(elementImpacted);
10323
10749
  };
10750
+ // Reading the transform on either side of the clear is what lets this work
10751
+ // without knowing anything about the element: how it looked while held and
10752
+ // how it looks once let go are both just computed transforms, and the
10753
+ // animation has only to bridge the two.
10754
+ const cancelPositionAnimated = ({
10755
+ duration = cancelAnimationDuration,
10756
+ easing = cancelAnimationEasing,
10757
+ } = {}) => {
10758
+ const transformWhileHeld = getComputedStyle(elementImpacted).transform;
10759
+ cancelPosition();
10760
+ const transformAtRest = getComputedStyle(elementImpacted).transform;
10761
+ if (transformWhileHeld === transformAtRest) {
10762
+ return null;
10763
+ }
10764
+ // No fill: the element already sits at its resting transform, the
10765
+ // animation only replays the way back to it.
10766
+ return elementImpacted.animate(
10767
+ [{ transform: transformWhileHeld }, { transform: transformAtRest }],
10768
+ { duration, easing },
10769
+ );
10770
+ };
10324
10771
  const commitPosition = () => {
10325
10772
  dragStyleController.commit(elementImpacted);
10326
10773
  };
10327
10774
  dragGesture.gestureInfo.cancelPosition = cancelPosition;
10775
+ dragGesture.gestureInfo.cancelPositionAnimated = cancelPositionAnimated;
10328
10776
  dragGesture.gestureInfo.commitPosition = commitPosition;
10329
10777
 
10330
10778
  dragGesture.addReleaseCallback(() => {
10331
10779
  if (releasePositionEffect === "cancel") {
10332
10780
  cancelPosition();
10781
+ } else if (releasePositionEffect === "cancel-animated") {
10782
+ cancelPositionAnimated();
10333
10783
  } else if (releasePositionEffect === "commit") {
10334
10784
  commitPosition();
10335
10785
  }
@@ -10522,7 +10972,15 @@ const createDragToMoveGestureController = ({
10522
10972
  // Build the transform to apply, preserving any transforms that were
10523
10973
  // already on the element before the grab (e.g. rotate from another
10524
10974
  // controller), and accumulating from the pre-grab translate baseline.
10525
- const transform = { ...transformAtGrab };
10975
+ // The translate keys are seeded HERE, before the spread, and not merely
10976
+ // assigned below: a transform object is serialized in key order, and in a
10977
+ // transform list every function transforms the frame of the ones after it.
10978
+ // A translate written after a rotate or a scale therefore travels rotated
10979
+ // and scaled — the element drifts away from the pointer, proportionally to
10980
+ // the distance covered. Dragging moves things on screen, so its translate
10981
+ // has to come first, whatever else the element carries. The spread still
10982
+ // wins on the value when the element already had a translate of its own.
10983
+ const transform = { translateX: 0, translateY: 0, ...transformAtGrab };
10526
10984
  if (direction.x) {
10527
10985
  const leftTarget = positionedLeft;
10528
10986
  const leftAtGrab = dragGesture.gestureInfo.leftAtGrab;
@@ -10910,9 +11368,9 @@ installImportMetaCssBuild(import.meta);const css$1 = /* css */`
10910
11368
 
10911
11369
  /* WHO CAN START A DRAG, said in the cursor.
10912
11370
  A handle drags on the spot, so it shows the hand. A source only drags once
10913
- the pointer has travelled a few pixels a plain click stays a click — but
10914
- the text inside it can no longer be selected (the gesture takes the
10915
- pointer), so an I-beam over it would promise something that does not
11371
+ the intent shows (a few pixels of travel, or a long press) a plain click
11372
+ stays a click — but the text inside it cannot be selected (the gesture takes
11373
+ the pointer), so an I-beam over it would promise something that does not
10916
11374
  happen: it reads as a plain surface instead. An opted-out area keeps both
10917
11375
  its cursor and its selection, and never starts a drag (see the check in
10918
11376
  startDragToReorder).
@@ -10986,7 +11444,9 @@ const dragCSSVars = ["--drop-hint-size", "--drop-hint-background-color", "--drop
10986
11444
  * Starts a drag-to-reorder interaction on a list item.
10987
11445
  *
10988
11446
  * Handles the full reorder UX:
10989
- * - Activates only after a short movement threshold (avoids accidental reorders on clicks).
11447
+ * - Activates only once the intent is established — a short movement with a mouse, a long
11448
+ * press with a finger (see `dragAfterIntent`), so that neither a click nor a scroll
11449
+ * reorders anything by accident.
10990
11450
  * - Clones the grabbed element and moves the clone while the original stays hidden in place
10991
11451
  * (keeps the layout intact so other items don't shift during the drag).
10992
11452
  * - CSS vars (`--drop-hint-size`, `--drop-hint-background-color`, etc.) are read from the
@@ -11009,30 +11469,48 @@ const dragCSSVars = ["--drop-hint-size", "--drop-hint-background-color", "--drop
11009
11469
  * - Virtual lists render fewer DOM nodes than the total item count, so
11010
11470
  * DOM-index-based counting would be wrong.
11011
11471
  *
11472
+ * Any option not listed below is forwarded to `createDragToMoveGestureController`
11473
+ * (`areaConstraint`, `autoScrollAreaPadding`, `stickyFrontiers`…), except
11474
+ * `releasePositionEffect`, always `"manual"` here: what moves is the clone, and it
11475
+ * is removed on release, so there is no position to commit or cancel.
11476
+ *
11012
11477
  * @param {PointerEvent} event
11013
- * The `pointerdown` event from the drag handle.
11014
- * @param {Element} draggedElement
11015
- * The list item element to drag. Typically `event.currentTarget`.
11478
+ * The `pointerdown` event that may become a reorder.
11016
11479
  * @param {object} options
11480
+ * @param {Element} [options.draggedElement=event.currentTarget]
11481
+ * The list item to drag.
11482
+ * @param {Element} [options.containerElement=draggedElement.parentElement]
11483
+ * Element searched with `itemSelector` to find the items to drop between.
11017
11484
  * @param {string} options.itemSelector
11018
- * CSS selector that matches all list items inside the scroll container.
11485
+ * CSS selector that matches all list items inside `containerElement`.
11019
11486
  * Used for drop-target detection and no-op filtering.
11020
11487
  * @param {function} options.getItemId
11021
11488
  * Returns the stable ID for a given DOM element.
11022
11489
  * Signature: `getItemId(element) → id`.
11023
11490
  * @param {function} options.onReorder
11024
11491
  * Called when the user drops the item in a new position.
11025
- * Signature: `onReorder(fromId, toId)`.
11492
+ * Signature: `onReorder(fromId, toId, syncCloneWithDropTarget)`.
11026
11493
  * - `fromId`: stable ID of the dragged item.
11027
11494
  * - `toId`: stable ID of the item to insert before, or `null` to append at the end.
11028
- * Called inside `document.startViewTransition` so the resulting DOM mutation is
11029
- * animated by the View Transitions API.
11495
+ * - `syncCloneWithDropTarget`: call it synchronously inside a
11496
+ * `document.startViewTransition` callback, next to the DOM mutation, so the
11497
+ * clone is captured at its landing position.
11030
11498
  * @param {object} [options.direction={ x: false, y: true }]
11031
11499
  * Axes along which dragging is allowed. Passed to `createDragToMoveGestureController`.
11032
- * @param {...*} [options]
11033
- * Any remaining options are forwarded to `createDragToMoveGestureController`
11034
- * (e.g. `areaConstraint`, `autoScrollAreaPadding`, `stickyFrontiers`).
11035
- * `releasePositionEffect` is always set to `"manual"` internally and cannot be overridden.
11500
+ * @param {number} [options.threshold=5]
11501
+ * Distance (px) a mouse must travel before the press becomes a drag.
11502
+ * @param {boolean|"if-touch"} [options.longPress="if-touch"]
11503
+ * Which pointers start the drag by holding still instead of by travelling.
11504
+ * @param {number} [options.longPressDelay=400]
11505
+ * How long (ms) such a pointer must stay down.
11506
+ * @param {number} [options.longPressSlop=8]
11507
+ * How far (px) it may drift during that wait before the press is abandoned.
11508
+ * @param {function} [options.onPressStart]
11509
+ * The pointer went down and the wait began (a cue that the press counts).
11510
+ * @param {function} [options.onPressCancel]
11511
+ * The pointer moved or lifted before the wait was over.
11512
+ * @param {function} [options.onPress]
11513
+ * The wait completed and the item is now held (haptics, scale…).
11036
11514
  */
11037
11515
  const startDragToReorder = (event, {
11038
11516
  draggedElement = event.currentTarget,
@@ -11044,6 +11522,13 @@ const startDragToReorder = (event, {
11044
11522
  x: false,
11045
11523
  y: true
11046
11524
  },
11525
+ threshold,
11526
+ longPress,
11527
+ longPressDelay,
11528
+ longPressSlop,
11529
+ onPressStart,
11530
+ onPressCancel,
11531
+ onPress,
11047
11532
  ...options
11048
11533
  }) => {
11049
11534
  // An area that opted out of dragging (a text one wants to select, a control
@@ -11056,7 +11541,7 @@ const startDragToReorder = (event, {
11056
11541
  return undefined;
11057
11542
  }
11058
11543
  event.preventDefault();
11059
- return dragAfterThreshold(event, () => {
11544
+ return dragAfterIntent(event, () => {
11060
11545
  const cloneWrapper = createDragClone(draggedElement, event);
11061
11546
  draggedElement.setAttribute("navi-drag-clone-source", "");
11062
11547
  // Move drag related CSS vars from the element to the document
@@ -11179,6 +11664,14 @@ const startDragToReorder = (event, {
11179
11664
  cloneWrapper.remove();
11180
11665
  });
11181
11666
  return dragGesture;
11667
+ }, {
11668
+ threshold,
11669
+ longPress,
11670
+ longPressDelay,
11671
+ longPressSlop,
11672
+ onPressStart,
11673
+ onPressCancel,
11674
+ onPress
11182
11675
  });
11183
11676
  };
11184
11677
 
@@ -11332,6 +11825,761 @@ const getResizeDirection = (element) => {
11332
11825
  return { x, y };
11333
11826
  };
11334
11827
 
11828
+ installImportMetaCssBuild(import.meta);/**
11829
+ * What a drag means when it TRAVELS: a whole screen pushed aside to bring in the
11830
+ * next one — slides inside one box, pages that are URLs.
11831
+ *
11832
+ * The pointer itself is not read here: reading a press, waiting for it to become
11833
+ * a gesture, capturing it, measuring how fast it goes, swallowing the click it
11834
+ * would have made is one gesture system for the whole codebase
11835
+ * (@jsenv/dom's drag_gesture + drag_after_intent), and this asks it for the
11836
+ * plain version — nothing carried, so no backdrop over the page, nothing made
11837
+ * inert, no focus taken: a screen slides and the page keeps its scrolling and
11838
+ * its keyboard.
11839
+ *
11840
+ * What IS here is everything that makes a travel a travel rather than a
11841
+ * carry — and it is policy, not plumbing:
11842
+ * - the axis is LOCKED by the first movement, instead of being constrained
11843
+ * ahead of time;
11844
+ * - a press becomes a gesture by distance for every pointer, finger included:
11845
+ * a swipe is a travel, and asking a finger to hold still first (the rule for
11846
+ * picking an object up) would mean waiting before being allowed to swipe;
11847
+ * - what travels walks ONE BOX, resists past its ends, and is measured from
11848
+ * where the finger is once it gets there — unless the caller has another box
11849
+ * to offer at that edge, and then the gesture walks on into it;
11850
+ * - letting go is a question with an answer: a third of a box, or a flick.
11851
+ *
11852
+ * What is NOT here is geometry: how big a box is, what lies one step that way,
11853
+ * what to paint while the finger moves. The caller knows those and nothing else
11854
+ * does — this reads the gesture and calls back.
11855
+ *
11856
+ * Who owns a gesture is decided in two places, and both are read here:
11857
+ * - what says so itself, with [data-no-drag-travel] or by being a field — a
11858
+ * component that reads the pointer marks itself, because the container it
11859
+ * ends up in cannot know what it is;
11860
+ * - a scroller between the pointer and the box with room left that way, which
11861
+ * keeps the gesture until it has none.
11862
+ */
11863
+
11864
+ // While a pointer is on something that travels: said on the document, because
11865
+ // what has to be told is the document.
11866
+ const GESTURE_ATTRIBUTE = "data-drag-travel-gesture";
11867
+
11868
+ // …and while one is actually travelling something, which is a later moment and
11869
+ // takes more away (see the CSS).
11870
+ const WALKING_ATTRIBUTE = "data-drag-travel-walking";
11871
+ import.meta.css = [/* css */`
11872
+ :root[${GESTURE_ATTRIBUTE}] {
11873
+ /* The bounce the browser plays when a gesture reaches the end of a page —
11874
+ and the swipe that goes back in history with it. Both are the browser
11875
+ answering a gesture that is already answered, here, by what the finger is
11876
+ dragging: the page rocks under a travel that is doing its own moving, and
11877
+ one gesture is seen twice. From the press, because the browser starts
11878
+ answering from the press — waiting for the first pixel that travels would
11879
+ let it happen once, every time. Only while a finger is down, so a page
11880
+ that bounces the rest of the time goes on bouncing. */
11881
+ overscroll-behavior: none;
11882
+ }
11883
+ /* …and nothing inside a travelling box hands its leftovers to what is above
11884
+ it: a list that reaches its end passes what is left of the gesture up the
11885
+ chain, and the page moves behind a travel that is being dragged.
11886
+
11887
+ Written ONCE AND FOR ALL rather than while a finger is down, unlike
11888
+ everything else here: a browser decides what a gesture may do when the
11889
+ gesture BEGINS — at the touchstart, at the first wheel event — and a
11890
+ property written after that decision arrives too late for the gesture it
11891
+ was meant for. That is what "most of the time it does not move, sometimes
11892
+ it does" is made of.
11893
+
11894
+ On the axis the box travels on, and that one only: the other axis is the
11895
+ content's own scrolling and is left alone. Containing does not stop it from
11896
+ scrolling anyway — it stops it from spilling over.
11897
+
11898
+ !important because this is not a preference: a box that travels cannot let
11899
+ the page travel with it, and the rule has to win over whatever an
11900
+ application says about its own scrollers. */
11901
+ [data-drag-travel*="x"],
11902
+ [data-drag-travel*="x"] * {
11903
+ overscroll-behavior-x: contain !important;
11904
+ }
11905
+ [data-drag-travel*="y"],
11906
+ [data-drag-travel*="y"] * {
11907
+ overscroll-behavior-y: contain !important;
11908
+ }
11909
+ :root[${WALKING_ATTRIBUTE}] {
11910
+ /* A drag over text selects it on the way, and the blue trail says the
11911
+ gesture was understood as something else. Not from the press: a press on
11912
+ text IS how one selects it, and only a press that has become a travel has
11913
+ said it was about something else. */
11914
+ user-select: none;
11915
+ }
11916
+ `, "@jsenv/dom/src/interaction/drag/drag_to_travel.js"];
11917
+
11918
+ // How far a pointer goes before it is a travel rather than a click: below this
11919
+ // a press that wandered a pixel is still a press, and nothing budges.
11920
+ const DRAG_START_THRESHOLD = 10;
11921
+ // How much of a box has to be pulled for letting go to carry on rather than put
11922
+ // things back. Under half, because a gesture that has clearly begun is an
11923
+ // intention: asking for the box to be dragged all the way across turns a travel
11924
+ // into work.
11925
+ const DRAG_COMMIT_RATIO = 0.3;
11926
+ // A flick travels whatever the distance: the hand said "away" quickly, which is
11927
+ // the whole gesture — px/ms of pointer, and a few pixels to tell it from a tap
11928
+ // that shook.
11929
+ const DRAG_FLICK_VELOCITY = 0.4;
11930
+ const DRAG_FLICK_DISTANCE = 8;
11931
+ // Pulling towards nothing: what travels follows at a fraction of the finger, so
11932
+ // the gesture is answered (something moves) while saying there is nothing that
11933
+ // way. Let go and it comes back — a wall one can lean on, never walk through.
11934
+ const DRAG_RESISTANCE = 0.3;
11935
+
11936
+ // What a drag must not start on: something that reads the pointer itself. A
11937
+ // button or a link is not in the list — dragging from one travels, and the
11938
+ // click it would have made is swallowed on the way out.
11939
+ const DRAG_EXCLUDED_SELECTOR = ["input", "textarea", "select", '[contenteditable=""]', '[contenteditable="true"]', "[data-no-drag-travel]"].join(",");
11940
+
11941
+ /**
11942
+ * A scroller between the pointer and the box it is in, with room left the way
11943
+ * the gesture goes: it gets the gesture, and nothing travels — dragging a row
11944
+ * that scrolls sideways scrolls that row, and only a row with nowhere left to
11945
+ * go hands the travel over.
11946
+ */
11947
+ const scrollRoomTowards = (fromElement, stopElement, axis, sign) => {
11948
+ let element = fromElement;
11949
+ while (element && element !== stopElement && element.nodeType === 1) {
11950
+ const size = axis === "x" ? element.clientWidth : element.clientHeight;
11951
+ const scrollSize = axis === "x" ? element.scrollWidth : element.scrollHeight;
11952
+ if (scrollSize > size + 1) {
11953
+ const {
11954
+ overflowX,
11955
+ overflowY
11956
+ } = getComputedStyle(element);
11957
+ const overflow = axis === "x" ? overflowX : overflowY;
11958
+ if (overflow === "auto" || overflow === "scroll") {
11959
+ const position = axis === "x" ? element.scrollLeft : element.scrollTop;
11960
+ // Dragging the content one way reveals what is on the other side of
11961
+ // it: to the right means going back up the scroll.
11962
+ const room = sign > 0 ? position : scrollSize - size - position;
11963
+ if (room > 1) {
11964
+ return true;
11965
+ }
11966
+ }
11967
+ }
11968
+ element = element.parentElement;
11969
+ }
11970
+ return false;
11971
+ };
11972
+
11973
+ // A gesture is over: does it carry on, or does everything go back? The distance
11974
+ // pulled says it, and the speed says it too — a short flick means "away" as
11975
+ // clearly as half a box does.
11976
+ const travelsAfter = ({
11977
+ pulled,
11978
+ slack,
11979
+ size,
11980
+ velocity,
11981
+ towardsSomething
11982
+ }) => {
11983
+ if (!towardsSomething) {
11984
+ return false;
11985
+ }
11986
+ // Caught in flight and let go of again without a word: what was on its way
11987
+ // carries on. Answered on the distance alone, a travel a hand merely touched
11988
+ // is undone BY the touch — it was stopped where it stood, and where it stood
11989
+ // is not far enough to count as an intention. Nobody asked it to stop; it was
11990
+ // asked to wait.
11991
+ if (slack && Math.abs(pulled - slack) < DRAG_START_THRESHOLD) {
11992
+ return true;
11993
+ }
11994
+ const sign = pulled > 0 ? 1 : -1;
11995
+ const goingFast = Math.abs(velocity) > DRAG_FLICK_VELOCITY;
11996
+ // A hand that is still moving says where it is going, and it says it about
11997
+ // BOTH answers. Going away from what it was bringing in is "put it back",
11998
+ // whatever the distance already covered — which is the whole of what one asks
11999
+ // for when catching something in flight and throwing it back the other way.
12000
+ // Without this the picture alone decides, and a screen caught at two thirds
12001
+ // and thrown back still arrives: the gesture was read as the place it was let
12002
+ // go of rather than as a movement.
12003
+ if (goingFast && Math.sign(velocity) !== sign) {
12004
+ return false;
12005
+ }
12006
+ // …and going towards it travels whatever the distance: the hand said "away"
12007
+ // quickly, which is the whole gesture.
12008
+ const flicked = goingFast && Math.abs(pulled) > DRAG_FLICK_DISTANCE;
12009
+ return flicked || Math.abs(pulled) > size * DRAG_COMMIT_RATIO;
12010
+ };
12011
+
12012
+ /**
12013
+ * Read a press, and tell the caller what the hand is doing with it.
12014
+ *
12015
+ * Called on pointerdown; returns a handle to stop the gesture, or null when the
12016
+ * press is not one this can be about (a right click, something that reads the
12017
+ * pointer itself).
12018
+ *
12019
+ * The gesture has no shape until the finger says which way it goes: `onStart`
12020
+ * is what turns a press into a travel, and it is asked at that moment rather
12021
+ * than when the finger landed, because whatever was moving then may have
12022
+ * arrived since.
12023
+ *
12024
+ * @param {PointerEvent} pointerDownEvent
12025
+ * @param {object} options
12026
+ * @param {Element} options.element - the box the gesture is about, and what the
12027
+ * pointer is captured on: it outlives whatever the caller does about the
12028
+ * travel, which the element under the finger may not.
12029
+ * @param {"x"|"y"|"xy"} [options.axes="xy"] - which ways this box can travel. A
12030
+ * finger leaning on any other axis is given up on at once, whole, so whatever
12031
+ * else wants it (a scroller, the page) gets it whole.
12032
+ * @param {false|"x"|"y"} [options.immediate=false] - the axis this press is
12033
+ * already on, for a press that landed on something moving: the gesture is
12034
+ * then read from its first pixel instead of waiting for an intent, and every
12035
+ * pixel since the grab is owed to the hand. The axis comes from the caller
12036
+ * rather than from the movement, because there is nothing to decide — what
12037
+ * was caught is travelling on one already.
12038
+ * @param {(detail: {axis: string, sign: number, target: Element, event: PointerEvent}) => false|{size: number, slack?: number, travelBack?: boolean, travelOn?: boolean}} options.onStart
12039
+ * - the finger has picked its axis. Answer `false` to give the gesture up, or
12040
+ * with the geometry it walks: `size` (one box along that axis), `slack` (how
12041
+ * far the box already sits from its resting place, for a travel grabbed
12042
+ * mid-flight) and whether there is anywhere to go each way — `travelBack`
12043
+ * towards the start of the axis, `travelOn` towards its end. A direction with
12044
+ * nothing there is not refused, it resists.
12045
+ * @param {(detail: {axis: string, pulled: number, size: number, progress: number, event: PointerEvent}) => void} options.onPull
12046
+ * - the finger has moved. `pulled` is in px from the resting place, `progress`
12047
+ * the same as a fraction of the box, signed the same way.
12048
+ * @param {(detail: {axis: string, sign: number, event: PointerEvent}) => false|{size: number, travelBack?: boolean, travelOn?: boolean}} [options.onEdge]
12049
+ * - the hand has reached an end of the box it holds and keeps going: `sign`
12050
+ * says which one — the far edge, a box walked whole, or its start, a box
12051
+ * walked back to where it began. Answer with the geometry of the box that
12052
+ * lies that way to hand the gesture over to it: the pixels past the end
12053
+ * become its first ones, so nothing is spent twice and the hand feels one
12054
+ * continuous movement. Answer `false` (or leave it out) for a wall — the
12055
+ * gesture stays on the box it has and leans on it.
12056
+ * @param {(detail: {axis: string, pulled: number, size: number, sign: number, travels: boolean, cancelled: boolean, event: PointerEvent}) => void} options.onEnd
12057
+ * - the finger is off. `travels` is the gesture's answer: carry on to what was
12058
+ * being pulled in, or put things back.
12059
+ * @param {() => void} [options.onGiveUp] - the press is over without ever
12060
+ * becoming a travel: it stayed still, leaned the wrong way, or `onStart`
12061
+ * refused it. Nothing was painted and nothing has to be put back — this is
12062
+ * only so the caller can forget the gesture it is holding.
12063
+ */
12064
+ const startDragToTravel = (pointerDownEvent, {
12065
+ element,
12066
+ axes = "xy",
12067
+ immediate = false,
12068
+ onStart,
12069
+ onPull,
12070
+ onEnd,
12071
+ onEdge = () => false,
12072
+ onGiveUp = () => {}
12073
+ }) => {
12074
+ const target = pointerDownEvent.target;
12075
+ if (!target.closest || target.closest(DRAG_EXCLUDED_SELECTOR)) {
12076
+ return null;
12077
+ }
12078
+
12079
+ // The travel in hand: null until the finger has picked an axis and the caller
12080
+ // has accepted it.
12081
+ let travel = null;
12082
+ let dragGesture = null;
12083
+ let over = false;
12084
+ const finish = () => {
12085
+ if (over) {
12086
+ return;
12087
+ }
12088
+ over = true;
12089
+ document.documentElement.removeAttribute(GESTURE_ATTRIBUTE);
12090
+ document.documentElement.removeAttribute(WALKING_ATTRIBUTE);
12091
+ window.removeEventListener("pointerup", onPressOver);
12092
+ window.removeEventListener("pointercancel", onPressOver);
12093
+ };
12094
+ // A press that never became a travel: the intent never resolved, or the axis
12095
+ // it leaned on is not one this box walks. Nothing was painted and nothing has
12096
+ // to be put back — the caller is only told so it can forget the gesture.
12097
+ const onPressOver = pointerEvent => {
12098
+ if (pointerEvent.pointerId !== pointerDownEvent.pointerId || travel) {
12099
+ return;
12100
+ }
12101
+ finish();
12102
+ onGiveUp();
12103
+ };
12104
+ const giveUp = () => {
12105
+ finish();
12106
+ dragGesture?.release();
12107
+ onGiveUp();
12108
+ };
12109
+
12110
+ // Where the picture stands, from what the gesture reports: the distance the
12111
+ // pointer has covered along the axis, less the pixels spent deciding — what
12112
+ // travels starts moving from where the finger is at that moment rather than
12113
+ // jumping the threshold it just crossed.
12114
+ // How far the POINTER has come along an axis. The raw distance, not the
12115
+ // layout the gesture computes for something being carried: nothing is being
12116
+ // carried here, and a scroll happening meanwhile must not read as a finger
12117
+ // that moved.
12118
+ const coveredOn = (axis, gestureInfo) => axis === "x" ? gestureInfo.dragX - gestureInfo.grabX : gestureInfo.dragY - gestureInfo.grabY;
12119
+ const pullOf = gestureInfo => {
12120
+ const covered = coveredOn(travel.axis, gestureInfo);
12121
+ return travel.slack + (covered - travel.origin);
12122
+ };
12123
+
12124
+ // Another box under the same hand, at either end of the one it holds. The
12125
+ // distance already covered on that side becomes the new box's own, measured
12126
+ // from where the finger IS: nothing is spent twice, and the gesture is one
12127
+ // movement rather than a wall the hand had to let go of to cross.
12128
+ // Returns where the new box stands, or null when there is nothing that way.
12129
+ const relayTo = (sign, distance, gestureInfo) => {
12130
+ const next = onEdge({
12131
+ axis: travel.axis,
12132
+ sign,
12133
+ event: gestureInfo.dragEvent
12134
+ });
12135
+ if (!next || !next.size) {
12136
+ return null;
12137
+ }
12138
+ travel.size = next.size;
12139
+ travel.travelBack = Boolean(next.travelBack);
12140
+ travel.travelOn = Boolean(next.travelOn);
12141
+ travel.slack = 0;
12142
+ let pulled = distance;
12143
+ if (pulled > next.size) {
12144
+ pulled = next.size;
12145
+ } else if (pulled < -next.size) {
12146
+ pulled = -next.size;
12147
+ }
12148
+ travel.origin = coveredOn(travel.axis, gestureInfo) - pulled;
12149
+ return pulled;
12150
+ };
12151
+ const controller = createDragGestureController({
12152
+ // The threshold is left at its default and never crossed: what says this
12153
+ // press has become a gesture is the intent module below, which calls
12154
+ // start() itself. Zero here would mean "started from the grab", and a
12155
+ // gesture that starts on its own is never STARTED — the moment that
12156
+ // installs the click it must swallow and the touch it must refuse would
12157
+ // never come.
12158
+ // Nothing is being carried: the page keeps its focus, its scrolling and its
12159
+ // cursor while a screen slides under the finger. That is the whole
12160
+ // difference with a drag that moves an object, and it is one option.
12161
+ documentInteractions: "manual",
12162
+ onDragStart: () => {
12163
+ document.documentElement.setAttribute(GESTURE_ATTRIBUTE, "");
12164
+ },
12165
+ onDrag: gestureInfo => {
12166
+ // Releasing a gesture reports one last move, so giving one up would come
12167
+ // back through here and give it up again, forever.
12168
+ if (over) {
12169
+ return;
12170
+ }
12171
+ if (!travel) {
12172
+ let axis;
12173
+ if (immediate) {
12174
+ // The axis is not up for decision: what this press caught is already
12175
+ // travelling on one, and the caller said which. The first pixel of a
12176
+ // hand landing on something moving is a tremor as often as it is a
12177
+ // direction — read as a lean across the axis, it gives the gesture up
12178
+ // and lets go of what was caught, under a finger that has not asked
12179
+ // for anything yet.
12180
+ axis = immediate;
12181
+ } else {
12182
+ // ONE axis, decided by the first movement reported and never
12183
+ // revisited: a diagonal would ask for two travels at once and only
12184
+ // one thing can arrive.
12185
+ const reachX = Math.abs(coveredOn("x", gestureInfo));
12186
+ const reachY = Math.abs(coveredOn("y", gestureInfo));
12187
+ if (!reachX && !reachY) {
12188
+ return;
12189
+ }
12190
+ axis = reachX >= reachY ? "x" : "y";
12191
+ if (!axes.includes(axis)) {
12192
+ giveUp();
12193
+ return;
12194
+ }
12195
+ }
12196
+ const covered = coveredOn(axis, gestureInfo);
12197
+ if (!covered) {
12198
+ // Nothing said on that axis yet: a grab without a movement, or one
12199
+ // straight across it. There is no gesture in that and nothing to give
12200
+ // up on either — whatever the caller caught at the press stays
12201
+ // caught, and the next report will say.
12202
+ if (immediate) {
12203
+ return;
12204
+ }
12205
+ giveUp();
12206
+ return;
12207
+ }
12208
+ const sign = Math.sign(covered);
12209
+ const started = onStart({
12210
+ axis,
12211
+ sign,
12212
+ target,
12213
+ event: gestureInfo.dragEvent
12214
+ });
12215
+ if (!started || !started.size) {
12216
+ giveUp();
12217
+ return;
12218
+ }
12219
+ travel = {
12220
+ axis,
12221
+ size: started.size,
12222
+ travelBack: Boolean(started.travelBack),
12223
+ travelOn: Boolean(started.travelOn),
12224
+ slack: started.slack || 0,
12225
+ // The pixels spent deciding the axis are not pulled back — what
12226
+ // travels sets off from where the finger is at that moment rather
12227
+ // than jumping the threshold it just crossed. Except when the intent
12228
+ // was established before the press (see immediate): there was no
12229
+ // threshold to cross, so every pixel since the grab is the hand's and
12230
+ // is owed to it.
12231
+ origin: immediate ? 0 : covered,
12232
+ pulled: started.slack || 0
12233
+ };
12234
+ document.documentElement.setAttribute(WALKING_ATTRIBUTE, axis);
12235
+ }
12236
+ const {
12237
+ axis
12238
+ } = travel;
12239
+ let pulled = pullOf(gestureInfo);
12240
+ // Which side is being pulled in: dragging to the right brings in what is
12241
+ // on the left, which is what comes BEFORE.
12242
+ let towardsSomething = pulled > 0 ? travel.travelBack : travel.travelOn;
12243
+ // Past the start of the box in hand, and the caller has a box that way:
12244
+ // the hand is not leaning on a wall, it is walking into the next one
12245
+ // backwards. Asked before the resistance, so what it is handed is the
12246
+ // hand's own distance rather than a damped one.
12247
+ if (!towardsSomething && pulled) {
12248
+ const relayed = relayTo(pulled > 0 ? 1 : -1, pulled, gestureInfo);
12249
+ if (relayed !== null) {
12250
+ pulled = relayed;
12251
+ towardsSomething = true;
12252
+ }
12253
+ }
12254
+ let size = travel.size;
12255
+ if (!towardsSomething) {
12256
+ pulled *= DRAG_RESISTANCE;
12257
+ }
12258
+ if (pulled > size || pulled < -size) {
12259
+ const sign = pulled > 0 ? 1 : -1;
12260
+ // How far past the edge the hand has gone. Its own number, because it
12261
+ // is what the next box is owed if there is one.
12262
+ const overshoot = pulled - sign * size;
12263
+ pulled = sign * size;
12264
+ if (towardsSomething) {
12265
+ // A box walked whole, and the finger still going: the caller may have
12266
+ // another one to put under it. Then the gesture WALKS ON — the pixels
12267
+ // past the edge are its first ones, so the hand feels one movement
12268
+ // and not a wall it had to let go of to cross.
12269
+ const relayed = relayTo(sign, overshoot, gestureInfo);
12270
+ if (relayed === null) {
12271
+ // A box travels one box, and the hand can go further than that.
12272
+ // Those extra pixels are not owed back: the gesture is measured
12273
+ // from where the finger IS once it has reached the end, so turning
12274
+ // around moves the picture at once instead of first walking back
12275
+ // over the distance the hand went too far.
12276
+ travel.origin = coveredOn(axis, gestureInfo) - (pulled - travel.slack);
12277
+ } else {
12278
+ pulled = relayed;
12279
+ size = travel.size;
12280
+ }
12281
+ }
12282
+ }
12283
+ travel.pulled = pulled;
12284
+ onPull({
12285
+ axis,
12286
+ pulled,
12287
+ size,
12288
+ progress: pulled / size,
12289
+ event: gestureInfo.dragEvent
12290
+ });
12291
+ },
12292
+ onRelease: gestureInfo => {
12293
+ if (over || !travel) {
12294
+ return;
12295
+ }
12296
+ finish();
12297
+ const {
12298
+ axis,
12299
+ size,
12300
+ pulled,
12301
+ slack
12302
+ } = travel;
12303
+ const towardsSomething = pulled > 0 ? travel.travelBack : travel.travelOn;
12304
+ const velocity = axis === "x" ? gestureInfo.velocityX : gestureInfo.velocityY;
12305
+ // A gesture taken away rather than let go of (the browser scrolling
12306
+ // something else, a call coming in) said nothing: things go back.
12307
+ const releaseEvent = gestureInfo.releaseEvent || gestureInfo.dragEvent;
12308
+ const cancelled = releaseEvent?.type === "pointercancel";
12309
+ onEnd({
12310
+ axis,
12311
+ pulled,
12312
+ size,
12313
+ sign: pulled > 0 ? 1 : -1,
12314
+ travels: !cancelled && travelsAfter({
12315
+ pulled,
12316
+ slack,
12317
+ size,
12318
+ velocity,
12319
+ towardsSomething
12320
+ }),
12321
+ cancelled,
12322
+ event: releaseEvent
12323
+ });
12324
+ }
12325
+ });
12326
+
12327
+ // When a press becomes a gesture, and by which rule. A travel is a swipe, so
12328
+ // the rule is the distance for EVERY pointer: the long press a finger is
12329
+ // asked for elsewhere says "pick this up and carry it", and asking for it
12330
+ // here would mean holding still before being allowed to swipe.
12331
+ const grab = () => {
12332
+ dragGesture = controller.grabViaPointer(pointerDownEvent, {
12333
+ element,
12334
+ // The box, not what the finger landed on: the caller's answer to this
12335
+ // gesture may take that away (a page that travels navigates, and the
12336
+ // router unmounts the page being left), and a capture whose element
12337
+ // leaves the document is a capture the browser drops.
12338
+ pointerCaptureElement: element
12339
+ });
12340
+ return dragGesture;
12341
+ };
12342
+ if (immediate) {
12343
+ // Already in the gesture: what this press landed on was moving, and a hand
12344
+ // that reaches for something in motion has said what it wants by reaching.
12345
+ // Asking it to prove it over ten pixels is asking twice — and over those
12346
+ // pixels the thing it is holding answers to nobody.
12347
+ grab()?.start();
12348
+ } else {
12349
+ dragAfterIntent(pointerDownEvent, grab, {
12350
+ longPress: false,
12351
+ threshold: DRAG_START_THRESHOLD
12352
+ });
12353
+ }
12354
+ window.addEventListener("pointerup", onPressOver);
12355
+ window.addEventListener("pointercancel", onPressOver);
12356
+ return {
12357
+ stop: () => {
12358
+ finish();
12359
+ dragGesture?.release();
12360
+ }
12361
+ };
12362
+ };
12363
+
12364
+ // A wheel gesture has no beginning and no end of its own: it is a stream of
12365
+ // events that starts when the fingers move and stops some time after they are
12366
+ // gone — the tail of it is the momentum the system keeps sending. So the end is
12367
+ // read from silence, and long enough to survive a page that is busy: the frames
12368
+ // right after a travel sets off are the ones where the main thread has the most
12369
+ // to do, and a silence read there as "the hand is gone" would cut one gesture
12370
+ // into several.
12371
+ const WHEEL_GESTURE_END_DELAY = 150;
12372
+ // What each screen AFTER the first costs inside one gesture. Deliberately
12373
+ // steep: reconstructing "how much did that flick mean" from a stream nobody
12374
+ // agrees on is guesswork, and a guess that overshoots leaves someone three
12375
+ // screens from where they were with no idea how they got there. Under-shooting
12376
+ // costs one more push. So the door is open for a gesture that insists, and shut
12377
+ // the rest of the time.
12378
+ const WHEEL_NEXT_STEP_DELTA = 600;
12379
+ // A stream that keeps getting weaker is momentum, not a hand: the system goes
12380
+ // on sending long after the fingers are gone. Counted, one flick becomes five
12381
+ // slides. Two events in a row are asked for rather than one, because a hand
12382
+ // wavers and momentum does not.
12383
+ const WHEEL_FADE_RUN = 2;
12384
+
12385
+ /**
12386
+ * A travel asked for with a wheel, and it asks for a WHOLE ONE.
12387
+ *
12388
+ * Two fingers swiping sideways on a trackpad, a mouse pushed sideways: the
12389
+ * browser sends `wheel` events and, left alone, answers them itself by
12390
+ * scrolling the page, bouncing it, or going back in history. Answering them
12391
+ * here is what stops that — a gesture is either ours or the browser's, and half
12392
+ * of each is what makes a page rock under a travel that is already moving.
12393
+ *
12394
+ * Read as STEPS and not as a distance, which is where this parts company with a
12395
+ * press: a hand on the box holds a screen and says where to put it, so it is
12396
+ * owed every pixel; a wheel points at the next screen and says "that one". What
12397
+ * travels is a row of slides, not a long strip one stops in the middle of, so
12398
+ * one push moves one slide — and the travel that follows plays at its own pace,
12399
+ * exactly as it would from a tab pressed or an arrow key.
12400
+ *
12401
+ * A gesture therefore moves ONE screen the moment it begins, on its first event
12402
+ * and whatever that event is worth: a hand that moved and saw nothing happen
12403
+ * does not wait, it pushes harder. Everything a threshold there would have
12404
+ * bought is bought instead by what the SECOND screen costs, which is a lot —
12405
+ * "how much did that flick mean" cannot be reconstructed from a stream nobody
12406
+ * agrees on, and a guess that overshoots leaves someone three screens away with
12407
+ * no idea how they got there. Under-shooting costs one more push, so that is
12408
+ * the side to be wrong on.
12409
+ *
12410
+ * The rest of the stream is mostly momentum, still arriving with the fingers
12411
+ * gone, and it must not be counted. What gives it away is that momentum only
12412
+ * ever WEAKENS: a stream that keeps shrinking is a push already answered, and a
12413
+ * number that grows again is a hand asking for more.
12414
+ *
12415
+ * @param {Element} element
12416
+ * @param {object} options
12417
+ * @param {"x"|"y"|"xy"} [options.axes="xy"] - which ways this box can travel.
12418
+ * The other one is the content's own scrolling and is left alone.
12419
+ * @param {(detail: {axis: string, sign: number, event: WheelEvent}) => void} options.onStep
12420
+ * - one push, one screen. `sign` is positive towards the start of the axis,
12421
+ * which brings in what comes BEFORE — a wheel says how far the CONTENT
12422
+ * scrolls, and pushing content to the right reveals its left.
12423
+ * @returns {() => void} stop listening.
12424
+ */
12425
+ const watchWheelTravel = (element, {
12426
+ axes = "xy",
12427
+ onStep
12428
+ }) => {
12429
+ let gesture = null;
12430
+ let endTimeout = null;
12431
+ const forgetGesture = () => {
12432
+ endTimeout = null;
12433
+ gesture = null;
12434
+ document.documentElement.removeAttribute(GESTURE_ATTRIBUTE);
12435
+ document.documentElement.removeAttribute(WALKING_ATTRIBUTE);
12436
+ };
12437
+
12438
+ // Where the hand thinks it is pushing. Not "what the event landed on":
12439
+ // while a view transition is playing, the browser delivers the wheel to the
12440
+ // document root rather than to the box under the pointer, whatever the
12441
+ // pseudo-elements are told about pointer-events. Heard on the box alone, a
12442
+ // gesture that sets a travel off loses every event after the first — and the
12443
+ // page scrolls behind the travel with everything that was not taken.
12444
+ const isOverElement = wheelEvent => {
12445
+ const {
12446
+ target
12447
+ } = wheelEvent;
12448
+ if (element.contains(target)) {
12449
+ return true;
12450
+ }
12451
+ // Something the box is INSIDE, which is what a wheel lands on while a view
12452
+ // transition has taken the box's rendering away: the hit falls through to
12453
+ // the nearest ancestor still being painted. That is the only case worth
12454
+ // measuring for, and asking it this way round costs a walk up the tree
12455
+ // rather than a layout read — a page can hold many travelling boxes, and
12456
+ // every one of them would otherwise measure itself on every wheel event
12457
+ // anywhere.
12458
+ if (!target.contains(element)) {
12459
+ return false;
12460
+ }
12461
+ const {
12462
+ left,
12463
+ right,
12464
+ top,
12465
+ bottom
12466
+ } = element.getBoundingClientRect();
12467
+ const {
12468
+ clientX,
12469
+ clientY
12470
+ } = wheelEvent;
12471
+ return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom;
12472
+ };
12473
+ const onWheel = wheelEvent => {
12474
+ if (!isOverElement(wheelEvent)) {
12475
+ return;
12476
+ }
12477
+ const axis = Math.abs(wheelEvent.deltaX) > Math.abs(wheelEvent.deltaY) ? "x" : "y";
12478
+ const delta = axis === "x" ? wheelEvent.deltaX : wheelEvent.deltaY;
12479
+ if (!delta) {
12480
+ return;
12481
+ }
12482
+ // Which way the screens go, said backwards: a wheel says how far the
12483
+ // CONTENT scrolls, and pushing content to the left brings in what is on the
12484
+ // right.
12485
+ const sign = delta > 0 ? -1 : 1;
12486
+ if (!gesture) {
12487
+ if (!axes.includes(axis)) {
12488
+ // The other axis: the content's own scrolling, left whole to whatever
12489
+ // wants it.
12490
+ return;
12491
+ }
12492
+ // Who owns it, asked once for the gesture rather than for every event of
12493
+ // it — the same two claims a press is read against (see the top of this
12494
+ // file), and both are answered by giving the gesture up whole: nothing is
12495
+ // prevented and the browser scrolls as it would have.
12496
+ const {
12497
+ target
12498
+ } = wheelEvent;
12499
+ if (target.closest && target.closest(DRAG_EXCLUDED_SELECTOR) || scrollRoomTowards(target, element, axis, sign)) {
12500
+ return;
12501
+ }
12502
+ gesture = {
12503
+ axis,
12504
+ sign,
12505
+ pushed: 0,
12506
+ lastMagnitude: 0,
12507
+ fadeRun: 0,
12508
+ stepped: false
12509
+ };
12510
+ document.documentElement.setAttribute(GESTURE_ATTRIBUTE, "");
12511
+ document.documentElement.setAttribute(WALKING_ATTRIBUTE, axis);
12512
+ }
12513
+ // Ours from here, on both axes: what the browser would do with the leftover
12514
+ // — scroll the page behind the box, bounce it, go back in history — is one
12515
+ // gesture answered twice.
12516
+ wheelEvent.preventDefault();
12517
+ clearTimeout(endTimeout);
12518
+ endTimeout = setTimeout(forgetGesture, WHEEL_GESTURE_END_DELAY);
12519
+ if (axis !== gesture.axis) {
12520
+ // The other axis mid-gesture: a hand is never perfectly straight, and the
12521
+ // axis was decided when the gesture set off.
12522
+ return;
12523
+ }
12524
+ if (sign !== gesture.sign) {
12525
+ // Turned around: what was adding up was going the other way.
12526
+ gesture.sign = sign;
12527
+ gesture.pushed = 0;
12528
+ gesture.lastMagnitude = 0;
12529
+ gesture.fadeRun = 0;
12530
+ gesture.stepped = false;
12531
+ }
12532
+ if (!gesture.stepped) {
12533
+ // The first event of a gesture moves a screen, whatever it is worth —
12534
+ // a pixel is a hand that moved, and a hand that moved and saw nothing
12535
+ // happen pushes harder rather than waiting. Everything a threshold could
12536
+ // buy here is bought by what a screen AFTER this one costs.
12537
+ gesture.stepped = true;
12538
+ onStep({
12539
+ axis: gesture.axis,
12540
+ sign: gesture.sign,
12541
+ event: wheelEvent
12542
+ });
12543
+ return;
12544
+ }
12545
+ const magnitude = Math.abs(delta);
12546
+ if (magnitude < gesture.lastMagnitude) {
12547
+ gesture.fadeRun += 1;
12548
+ } else if (magnitude > gesture.lastMagnitude) {
12549
+ // Back up again — a hand asking for more. Momentum never does this.
12550
+ gesture.fadeRun = 0;
12551
+ }
12552
+ gesture.lastMagnitude = magnitude;
12553
+ if (gesture.fadeRun >= WHEEL_FADE_RUN) {
12554
+ return;
12555
+ }
12556
+ gesture.pushed += magnitude;
12557
+ if (gesture.pushed < WHEEL_NEXT_STEP_DELTA) {
12558
+ return;
12559
+ }
12560
+ gesture.pushed = 0;
12561
+ onStep({
12562
+ axis: gesture.axis,
12563
+ sign: gesture.sign,
12564
+ event: wheelEvent
12565
+ });
12566
+ };
12567
+ document.addEventListener("wheel", onWheel, {
12568
+ passive: false,
12569
+ capture: true
12570
+ });
12571
+ return () => {
12572
+ document.removeEventListener("wheel", onWheel, {
12573
+ capture: true
12574
+ });
12575
+ clearTimeout(endTimeout);
12576
+ endTimeout = null;
12577
+ gesture = null;
12578
+ document.documentElement.removeAttribute(GESTURE_ATTRIBUTE);
12579
+ document.documentElement.removeAttribute(WALKING_ATTRIBUTE);
12580
+ };
12581
+ };
12582
+
11335
12583
  // Shared by navi's own use_displayed_layout_effect.js (rich "navi_displayed"
11336
12584
  // CustomEvent, open transitions only) and visible_rect.js (needs both
11337
12585
  // directions: hide when a container closes, recheck when it reopens) — the
@@ -16869,4 +18117,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
16869
18117
  };
16870
18118
  };
16871
18119
 
16872
- export { EASING, ELEMENT_SIZE_CHANGE, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, applyNewPosition, canScroll, captureScrollState, chainEvent, closestOpenableAncestor, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createEventGroupLogger, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findEvent, findFocusDelegateTarget, findFocusable, findSelfOrAncestorFixedPosition, formatEventSideEffect, getAncestorOpenType, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getKeyboardEventDefaultAction, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPositioningScrollOffset, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isAncestorOpen, isSameColor, isScrollable, measureLongestVisualLineWidth, measureScrollbar, measureWidestChildRow, mergeOneStyle, mergeTwoStyles, normalizeKeyboardKey, normalizeStyle, normalizeStyles, observeAncestorOpenState, onAncestorReopen, parsePositionArea, parseStyle, performTabNavigation, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, subscribeVisualViewportResizeSettled, subscribeWindowResizeSettled, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
18120
+ export { EASING, ELEMENT_SIZE_CHANGE, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, applyNewPosition, canScroll, captureScrollState, chainEvent, closestOpenableAncestor, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createEventGroupLogger, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterIntent, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findEvent, findFocusDelegateTarget, findFocusable, findSelfOrAncestorFixedPosition, formatEventSideEffect, getAncestorOpenType, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getKeyboardEventDefaultAction, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPositioningScrollOffset, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isAncestorOpen, isPrimaryButtonEvent, isSameColor, isScrollable, measureLongestVisualLineWidth, measureScrollbar, measureWidestChildRow, mergeOneStyle, mergeTwoStyles, normalizeKeyboardKey, normalizeStyle, normalizeStyles, observeAncestorOpenState, onAncestorReopen, parsePositionArea, parseStyle, performTabNavigation, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, scrollRoomTowards, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, startDragToTravel, stickyAsRelativeCoords, stringifyStyle, subscribeVisualViewportResizeSettled, subscribeWindowResizeSettled, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect, watchWheelTravel };