@jsenv/dom 0.17.25 → 0.17.27

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 +210 -7
  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
@@ -9287,7 +9477,8 @@ const css$4 = /* css */`
9287
9477
  not to the gesture, and two fingers are never a drag. */
9288
9478
  touch-action: pinch-zoom;
9289
9479
  }
9290
- [data-drag-ignore] {
9480
+ [data-drag-ignore],
9481
+ [data-own-target] {
9291
9482
  -webkit-touch-callout: default;
9292
9483
  touch-action: auto;
9293
9484
  }
@@ -11741,7 +11932,8 @@ const css$1 = /* css */`
11741
11932
  [data-drag-source] {
11742
11933
  cursor: default;
11743
11934
  }
11744
- [data-drag-ignore] {
11935
+ [data-drag-ignore],
11936
+ [data-own-target] {
11745
11937
  cursor: auto;
11746
11938
  }
11747
11939
 
@@ -11836,6 +12028,14 @@ const css$1 = /* css */`
11836
12028
  // can start a drag, and they have to be true BEFORE anyone drags anything.
11837
12029
  import.meta.css = [css$1, "@jsenv/dom/src/interaction/drag/drag_to.js"];
11838
12030
 
12031
+ // What a press must not be read from at all. `data-drag-ignore` is said by
12032
+ // something whose press is its own business — a text one wants to select, a
12033
+ // control that reads the pointer itself. `data-own-target` is the same fact said
12034
+ // once for every gesture there is: an element declaring that a press landing on
12035
+ // it is aimed AT it, whatever it happens to sit inside (see also
12036
+ // DRAG_EXCLUDED_SELECTOR in drag_to_travel.js).
12037
+ const DRAG_IGNORED_SELECTOR = "[data-drag-ignore],[data-own-target]";
12038
+
11839
12039
  /**
11840
12040
  * Starts a drag-to-reorder interaction on a list item.
11841
12041
  *
@@ -12335,7 +12535,7 @@ const startDragTo = (event, effects, {
12335
12535
  } = {}) => {
12336
12536
  // An area that opted out of dragging (a text one wants to select, a control that
12337
12537
  // owns the gesture): the press there is none of our business.
12338
- if (event.target.closest && event.target.closest("[data-drag-ignore]")) {
12538
+ if (event.target.closest && event.target.closest(DRAG_IGNORED_SELECTOR)) {
12339
12539
  return undefined;
12340
12540
  }
12341
12541
  // A secondary button (right click and friends) is a context menu, not a grab.
@@ -12517,7 +12717,7 @@ const startDragToCarryCopy = (event, {
12517
12717
  }) => {
12518
12718
  // An area that opted out of dragging (a text one wants to select, a control
12519
12719
  // that owns the gesture): the press there is none of our business.
12520
- if (event.target.closest && event.target.closest("[data-drag-ignore]")) {
12720
+ if (event.target.closest && event.target.closest(DRAG_IGNORED_SELECTOR)) {
12521
12721
  return undefined;
12522
12722
  }
12523
12723
  // A secondary button (right click and friends) is a context menu, not a grab.
@@ -13262,8 +13462,11 @@ const DRAG_RESISTANCE = 0.3;
13262
13462
  // from one travels, and the click it would have made is swallowed on the way
13263
13463
  // out. A drag SOURCE is not either: it says which way it goes and only takes
13264
13464
  // that (see DRAG_SOURCE_AXES_ATTRIBUTE) — but a dedicated handle is, being a
13265
- // place whose only purpose is to be taken hold of, from the first pixel.
13266
- const DRAG_EXCLUDED_SELECTOR = ["input", "textarea", "select", '[contenteditable=""]', '[contenteditable="true"]', "[data-drag-handle]", "[data-no-drag-travel]"].join(",");
13465
+ // place whose only purpose is to be taken hold of, from the first pixel. And so
13466
+ // is an OWN TARGET: an element saying a press landing on it is aimed at IT,
13467
+ // which is the same sentence said to every gesture at once rather than to this
13468
+ // one (see DRAG_IGNORED_SELECTOR in drag_to.js).
13469
+ const DRAG_EXCLUDED_SELECTOR = ["input", "textarea", "select", '[contenteditable=""]', '[contenteditable="true"]', "[data-drag-handle]", "[data-no-drag-travel]", "[data-own-target]"].join(",");
13267
13470
 
13268
13471
  // Which axes a box travels on, one attribute per gesture, said in the DOM by
13269
13472
  // whoever owns the box: it is what a box ABOVE another reads to know the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.17.25",
3
+ "version": "0.17.27",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {