@jsenv/dom 0.17.4 → 0.17.6

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