@jsenv/dom 0.17.25 → 0.17.26

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 +191 -1
  2. package/package.json +1 -1
package/dist/jsenv_dom.js CHANGED
@@ -7674,11 +7674,187 @@ const getPaddingSizes = (element) => {
7674
7674
  };
7675
7675
  };
7676
7676
 
7677
+ // A gesture on an element the browser paints in the top layer belongs to that
7678
+ // element, wherever it sits in the DOM.
7679
+ const TOP_LAYER_SELECTOR$1 = [
7680
+ ":popover-open",
7681
+ "dialog:modal",
7682
+ ":fullscreen",
7683
+ ].join(",");
7684
+
7685
+ // The keys that scroll the document when nothing focusable holds them.
7686
+ const SCROLL_KEY_SET = new Set([
7687
+ "ArrowUp",
7688
+ "ArrowDown",
7689
+ "ArrowLeft",
7690
+ "ArrowRight",
7691
+ "PageUp",
7692
+ "PageDown",
7693
+ "Home",
7694
+ "End",
7695
+ " ",
7696
+ ]);
7697
+
7698
+ /**
7699
+ * Stops the background from scrolling by cancelling the scroll gestures that
7700
+ * would reach it, leaving every style on the page untouched.
7701
+ *
7702
+ * **Why not overflow: hidden?**
7703
+ * A scroll container that cannot scroll has no sticky offsets: browsers only
7704
+ * apply `position: sticky` constraints to a scroller that can actually scroll.
7705
+ * Locking the document with `overflow: hidden` therefore drops every stuck
7706
+ * element (a sticky header, a sticky table column) back to its flow position —
7707
+ * off screen, since the page stays visually scrolled where it was. Cancelling
7708
+ * gestures keeps the scroller scrollable, so sticky, the scrollbar and the
7709
+ * layout width all stay exactly as they are (no gutter to compensate).
7710
+ *
7711
+ * **What it does not stop**
7712
+ * - dragging the native scrollbar: it sits outside the viewport, no backdrop
7713
+ * covers it and no event of ours is involved;
7714
+ * - programmatic scroll (`scrollTo`, `scrollIntoView`, a focus moving into the
7715
+ * background).
7716
+ * Both are deliberate acts on a background that is covered by a backdrop, hence
7717
+ * the requirement: without something covering the background, lock the overflow
7718
+ * instead (see `scroll_trap.js`).
7719
+ *
7720
+ * @param {HTMLElement} element - The overlay being shown. Gestures landing on
7721
+ * it (or on any other top layer element) are left alone.
7722
+ * @param {Object} [options]
7723
+ * @param {HTMLElement} [options.boundaryElement] - Only block gestures landing
7724
+ * inside this element. For an overlay confined to a local container: the
7725
+ * container must stop scrolling, the rest of the page keeps its gestures.
7726
+ * @param {HTMLElement} [options.backdropElement] - The element covering the
7727
+ * background, when it is one of ours (a modal dialog covers it with its own
7728
+ * `::backdrop` and there is nothing to pass). It is in the top layer like the
7729
+ * overlay itself, yet a gesture on it aims at the background behind it.
7730
+ * @returns {() => void} Cleanup function removing the listeners.
7731
+ */
7732
+
7733
+ const trapScrollGestureInside = (
7734
+ element,
7735
+ { boundaryElement, backdropElement } = {},
7736
+ ) => {
7737
+ const { ownerDocument } = element;
7738
+ const lockedRegion = boundaryElement || ownerDocument.documentElement;
7739
+
7740
+ const isBackgroundGesture = (target, clientX, clientY) => {
7741
+ if (!lockedRegion.contains(target)) {
7742
+ return false;
7743
+ }
7744
+ if (element.contains(target)) {
7745
+ // A modal <dialog> is the event target for the whole backdrop area too
7746
+ // (::backdrop is not hit-testable), so being the target does not mean the
7747
+ // pointer is on it — the rect decides, same as the backdrop-click
7748
+ // detection in dialog.jsx.
7749
+ if (target !== element) {
7750
+ return false;
7751
+ }
7752
+ const { left, right, top, bottom } = element.getBoundingClientRect();
7753
+ return (
7754
+ clientX < left || clientX > right || clientY < top || clientY > bottom
7755
+ );
7756
+ }
7757
+ if (backdropElement && backdropElement.contains(target)) {
7758
+ return true;
7759
+ }
7760
+ let ancestorOrSelf = target;
7761
+ while (ancestorOrSelf && ancestorOrSelf.nodeType === 1) {
7762
+ if (ancestorOrSelf.matches(TOP_LAYER_SELECTOR$1)) {
7763
+ return false;
7764
+ }
7765
+ ancestorOrSelf = ancestorOrSelf.parentNode;
7766
+ }
7767
+ return true;
7768
+ };
7769
+
7770
+ const onWheel = (wheelEvent) => {
7771
+ if (wheelEvent.ctrlKey) {
7772
+ // Browser zoom, not a scroll — cancelling it would take zoom away from
7773
+ // the page while an overlay is open.
7774
+ return;
7775
+ }
7776
+ if (
7777
+ isBackgroundGesture(
7778
+ wheelEvent.target,
7779
+ wheelEvent.clientX,
7780
+ wheelEvent.clientY,
7781
+ )
7782
+ ) {
7783
+ wheelEvent.preventDefault();
7784
+ }
7785
+ };
7786
+ const onTouchMove = (touchMoveEvent) => {
7787
+ const { touches } = touchMoveEvent;
7788
+ if (touches.length !== 1) {
7789
+ return; // pinch
7790
+ }
7791
+ const [touch] = touches;
7792
+ if (
7793
+ isBackgroundGesture(touchMoveEvent.target, touch.clientX, touch.clientY)
7794
+ ) {
7795
+ touchMoveEvent.preventDefault();
7796
+ }
7797
+ };
7798
+ const onKeyDown = (keydownEvent) => {
7799
+ if (!SCROLL_KEY_SET.has(keydownEvent.key)) {
7800
+ return;
7801
+ }
7802
+ const { target } = keydownEvent;
7803
+ // Anything else focused (a field, a scrollable box, a button) owns the key
7804
+ // press; only a document with nothing focused scrolls on it.
7805
+ if (
7806
+ target !== ownerDocument.body &&
7807
+ target !== ownerDocument.documentElement
7808
+ ) {
7809
+ return;
7810
+ }
7811
+ if (isBackgroundGesture(target)) {
7812
+ keydownEvent.preventDefault();
7813
+ }
7814
+ };
7815
+
7816
+ // Capture phase: the gesture must be cancelled before whatever it landed on
7817
+ // gets a chance to act on it. passive: false is what makes preventDefault
7818
+ // effective at all on wheel/touchmove.
7819
+ const listenerOptions = { capture: true, passive: false };
7820
+ ownerDocument.addEventListener("wheel", onWheel, listenerOptions);
7821
+ ownerDocument.addEventListener("touchmove", onTouchMove, listenerOptions);
7822
+ ownerDocument.addEventListener("keydown", onKeyDown, listenerOptions);
7823
+ return () => {
7824
+ ownerDocument.removeEventListener("wheel", onWheel, listenerOptions);
7825
+ ownerDocument.removeEventListener(
7826
+ "touchmove",
7827
+ onTouchMove,
7828
+ listenerOptions,
7829
+ );
7830
+ ownerDocument.removeEventListener("keydown", onKeyDown, listenerOptions);
7831
+ };
7832
+ };
7833
+
7834
+ // "gesture": cancel the scroll gestures aimed at the background, keeping it
7835
+ // scrollable (see scroll_gesture_trap.js) — only possible when a backdrop
7836
+ // covers it.
7837
+ // "overflow": hide the overflow of every scroll container behind the overlay.
7838
+ // Set this to "overflow" to put every scroll lock on that single strategy.
7839
+ const SCROLL_LOCK_STRATEGY = "gesture";
7840
+
7677
7841
  /**
7678
7842
  * Prevents scrolling on all scrollable containers that are ancestors of (or
7679
7843
  * siblings preceding) `element`. Used when an overlay (popover, dialog) is
7680
7844
  * open and background scroll should be disabled.
7681
7845
  *
7846
+ * **Two strategies, and why the gesture one comes first**
7847
+ * Hiding the overflow makes the scroller unable to scroll, and a scroller that
7848
+ * cannot scroll has no sticky offsets: every `position: sticky` element behind
7849
+ * the overlay drops back to its flow position — off screen, since the page
7850
+ * stays visually scrolled where it was. So whenever a backdrop covers the
7851
+ * background (`backdrop`), the lock is done by cancelling scroll gestures
7852
+ * (`scroll_gesture_trap.js`): nothing in the page moves, sticky included, and
7853
+ * there is no scrollbar to compensate for. The overflow lock below is what
7854
+ * remains when nothing covers the background — a gesture trap would then let
7855
+ * the user scroll by dragging the scrollbar of a container they can still see
7856
+ * and reach. `SCROLL_LOCK_STRATEGY` forces that overflow lock everywhere.
7857
+ *
7682
7858
  * **Why padding instead of scrollbar-gutter?**
7683
7859
  * `scrollbar-gutter: stable` would be the modern, CSS-native way to reserve
7684
7860
  * the scrollbar lane before hiding overflow so the layout doesn't shift.
@@ -7700,9 +7876,23 @@ const getPaddingSizes = (element) => {
7700
7876
  * inside this element (itself included). For an overlay confined to a local
7701
7877
  * container rather than the viewport: the container's own scroll must stop,
7702
7878
  * the rest of the page keeps scrolling as usual.
7879
+ * @param {true|HTMLElement} [options.backdrop] - What covers the background
7880
+ * while the overlay is open: the backdrop element, or `true` when the browser
7881
+ * paints it itself (a modal dialog's `::backdrop`). Enables the gesture
7882
+ * strategy described above.
7703
7883
  * @returns {() => void} Cleanup function that restores all modified styles.
7704
7884
  */
7705
- const trapScrollInside = (element, { boundaryElement } = {}) => {
7885
+ const trapScrollInside = (
7886
+ element,
7887
+ { boundaryElement, backdrop } = {},
7888
+ ) => {
7889
+ if (backdrop && SCROLL_LOCK_STRATEGY === "gesture") {
7890
+ return trapScrollGestureInside(element, {
7891
+ boundaryElement,
7892
+ backdropElement: backdrop === true ? null : backdrop,
7893
+ });
7894
+ }
7895
+
7706
7896
  const cleanupCallbackSet = new Set();
7707
7897
 
7708
7898
  // Collect every element to lock first (preceding scrollable siblings + all
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.17.25",
3
+ "version": "0.17.26",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {