diff --git a/.agents/skills/animations/SKILL.md b/.agents/skills/animations/SKILL.md index 6697e26be6..b1c506c012 100644 --- a/.agents/skills/animations/SKILL.md +++ b/.agents/skills/animations/SKILL.md @@ -106,12 +106,17 @@ slower. ## View transitions `document.startViewTransition` takes a callback that **makes the DOM change**, -so it can only be called by whoever owns the state: - -- **navi never starts one.** A component sees the change only once the DOM - already holds it — too late to capture the "before". navi declares what an - element does in a transition (names, keyframes); the application wraps its - own state change. _Reference: `itemTransition` on List._ +so it can only be called by whoever owns the change: + +- **A component that does not own the change never starts one.** It sees the + change only once the DOM already holds it — too late to capture the "before". + It declares instead what an element does in a transition (names, keyframes), + and the application wraps its own state change. That is most of navi. + _Reference: `itemTransition` on List._ +- **A component that DOES own the change starts it itself**, and that is not an + exception to the rule above but the same rule read the other way. A component + that navigates — it is the one calling the router — holds the "before" until + it decides to leave it. _Reference: `beginTravel` in route_travel.jsx._ - **Call it through `ensureDocumentStartViewTransition()`** (navi/src/transition): installs the API where missing, runs the update whatever happens, and swallows the rejection a _skipped_ transition produces @@ -119,16 +124,25 @@ so it can only be called by whoever owns the state: unhandled error). - **Keep the page out of it**: `:root { view-transition-name: none }`. The UA names the root `root`, so by default the whole document is replaced by a - picture for the duration — the page stops rendering live under it. Opted - out, only the named elements are captured. + picture for the duration — and a captured element is dead in BOTH senses: it + stops rendering live, and it cannot be pointed at anymore. Nothing hit-tests + to it; every press over it falls through to the nearest ancestor still being + painted, whatever the pseudo-elements are told about `pointer-events`. Opted + out, only the named elements are captured and the rest of the page keeps + answering. Even a transition that slides whole screens past each other does + not need the root: the travelling box is itself captured, so it paints + nothing of its own, and its two pictures cover its rectangle between them at + every moment of the travel. _Reference: the `TRAVEL_ATTRIBUTE` CSS in + route_travel.jsx._ Facts worth knowing before reaching for one: - **A name must be unique in the document** — a duplicate aborts the transition. Scope names by list, by picker, by whatever makes them unique. -- **Pass `viewTransitionName` as a Box prop, never through `style`** — the - prop drops the name while the element is clipped or off-screen, so it never - animates from a partial snapshot. +- **A name that depends on browser support is written in CSS, not in JS** — see + "Where a browser cannot nest, it must not animate" below. Box's + `viewTransitionName` prop (and inline styles in general) cannot be put behind + an `@supports`, so it only fits a name every browser may have. - **The layout changes at once.** A transition animates snapshots, not layout: the document height jumps the moment the callback runs. Wanting the layout itself to be seen moving means animating the real property (WAAPI), not a @@ -144,15 +158,257 @@ Facts worth knowing before reaching for one: user IS looking at (the rule above). Start one only when what changes is what the user is watching. +### The main thread lies about a running transition + +The pseudo-elements' animations run on the **compositor**, and everything JS +can read answers from the main thread instead. Three traps, each of which cost +an afternoon here, and the pattern behind all three is the same: the number +looks right, the screen disagrees, and only the screen is telling the truth. + +- **The `playbackRate` setter is a jump on screen.** For a composited + animation the setter is a non-seamless change: the pictures leap straight to + their end state while the `Animation` object goes on ticking (or ticking + backwards) unseen. Hand a new rate over with `updatePlaybackRate()` — the + seamless, async variant is what it exists for. +- **`getComputedStyle` on a `::view-transition-*` pseudo returns the + UN-animated value.** Where the pictures actually stand cannot be read from + JS at all. To know how far a travel visibly is, compute it from the clock + THROUGH the easing curve: the easing of a CSS animation sits on its + keyframes (`effect.getKeyframes()[0].easing`), and CSS `ease` is parametric + — solve it numerically. _Reference: `revertWalkTime` in route_travel.jsx._ +- **Screenshots lie too.** A re-rasterized capture (Playwright's + `page.screenshot`) is drawn from main-thread state: it shows the animation + where the `Animation` object says it is — pixel-perfect pictures of a + movement the screen never played. Only a compositor capture tells the truth: + a CDP screencast (`Page.startScreencast`), a screen recording, a human eye. + When a human reports a snap that every number says cannot happen, believe + the human and reach for the screencast. + +## What moves inside a box stays inside the box + +The pictures a transition takes are drawn in the top layer, all of them side by +side under `::view-transition`, a flat tree that has forgotten which element was +inside which. **No `overflow` anywhere in the document reaches them.** So the +moment something moves further than the box it lives in — a row travelling to a +position scrolled out of the list, a page pulled in from beside the container — +it is seen crossing the page, over whatever sits next to that box. Nothing about +it looks like a bug in the animation; it looks like the layout broke. + +What we want is simply the box's edge, kept during the transition. Two ways to +get it, and the choice is not a matter of taste: + +- **The box IS what moves** (its own pictures are bigger than it, the way two + pages sliding past each other are): clip the box's own group — + `::view-transition-group(name)` and `::view-transition-image-pair(name)`, + `overflow: clip`. _Reference: route_travel.jsx._ +- **Things move INSIDE the box** (rows of a list, slides of a container): the + box needs a picture of its own and the pictures of its contents must be drawn + inside it. That is a nested group: + + 1. the box gets a `view-transition-name` (`match-element` will do) and + `view-transition-group: contain`, which makes it the group everything named + inside it hangs under; + 2. `::view-transition-group-children(name)` — the box's edge — is told + `overflow: clip`; + 3. the box's own `::view-transition-old/new` are given `animation: none` and + `mix-blend-mode: normal`: only its contents moved, and cross-fading the box + onto itself is a flicker. + + The rows themselves say nothing. `view-transition-group: nearest` on a child + is the other half of the same feature — the child chooses its ancestor instead + of the ancestor claiming every name inside it — and is what to reach for when + the box must contain _these_ elements and not everything an application may + have named under it. + + _Reference: `itemTransition` in list.jsx (`.navi_list_transition`), demo + `src/control/demos/many/4_reorderable_list_demo.html`._ + +Clipping is not the only thing nesting restores: a group drawn inside another +also follows it. A row moving while the list itself moves is one movement, not +two that must be kept in step by hand. + +### Where a browser cannot nest, it must not animate + +Nesting is **Chrome/Edge 140+, and nothing else** (no Safari, no Firefox). Where +a movement is only correct BECAUSE of the clipping, a browser without it gets +**no transition at all** — the change just happens. What it would play instead is +not a lesser animation, it is content flying across the page, and that is worse +than no animation. A pretty movement on recent browsers, bought with simpler code +and better performance, is the deal being taken here — the others will follow. + +**The support test belongs in the CSS, not in the JS.** The whole movement is +written in CSS already; a flag read in JS to decide whether to call +`startViewTransition`, or to decide whether to pass a name, splits one decision +across two languages and leaves JS knowing about browsers. Put the names +themselves inside `@supports (view-transition-group: contain)`: nothing is named, +so nothing is captured, so the call — made unconditionally — animates nothing. + +```css +@supports (view-transition-group: contain) { + .box[data-item-transition] { + view-transition-name: match-element; /* a name it does not have to choose */ + view-transition-class: my_box; + view-transition-group: contain; + + [data-view-transition-name] { + view-transition-name: attr( + data-view-transition-name type() + ); + } + } +} +::view-transition-group-children(.my_box) { + overflow: clip; +} +``` + +The two ways a name gets written from a stylesheet, and when each is right: + +- **`match-element`** — the browser makes up a name, unique by construction, and + pairs old with new by the ELEMENT. For a box that is still itself across the + change (the list, the container), where the name is only needed so a group + exists, and where inventing a unique one in JS is pure ceremony. +- **`attr(data-… type())`** — pairs by whatever the attribute says. + For anything whose identity is its data and not its element: rows are recycled + as a list scrolls, so `match-element` there pairs the wrong two. JS writes the + attribute (an id, always, on every browser) and stays out of the decision. + +**"Nothing is named" is not "nothing happens": the UA still names the root.** So +on a browser left out, an unconditional `startViewTransition` cross-fades the +whole page — the change is not seen travelling anywhere, it is seen fading. It is +a decent default and it is why the call can stay unconditional. Where it is not +wanted, what turns it off is one line: + +```css +@supports not (view-transition-group: contain) { + :root { + view-transition-name: none; + } +} +``` + +**That line belongs to the application, never to a component.** It speaks for the +whole document, and only whoever owns the page knows whether a fade is a +downgrade or a glitch there — a list or a route travel writing it would be +deciding for every other transition on the page. So navi documents it where the +transition is turned on (`itemTransition`, RouteTravel) and leaves it to the +caller, case by case. + +What is left in JS is the `startViewTransition` call and an attribute. Nothing +tests a browser. + +## A movement a finger drives, when only the state has the second picture + +Some movements show two states at once while only one of them can exist in the +DOM: a router mounts the page that matches the URL and nothing else, and a +swipe between two of them needs both. The way out is not to mount what does not +exist — it is to let the **state lead and the picture follow**: change the state +first, and hand the picture the browser kept of the "before" to the finger. + +The gesture then drives a transition instead of driving pixels: + +- **Scrub, do not translate.** How the two pictures move is written in CSS + (keyframes on `::view-transition-old/new`); the finger only says how far in. + Take the animations on the pseudo-elements (`document.getAnimations()`, + `effect.pseudoElement`) and set `currentTime = ratio * duration`. Nothing + about the movement is duplicated in JS, so it stays a CSS concern. +- **Hold them from the first frame, in CSS** — `animation-play-state: paused` + under an attribute set before the transition starts — and NOT by pausing them + in JS when `ready` resolves. JS cannot pause what does not exist: the + animations appear with the transition, a navigation and a render after it was + asked for, and those frames are the beginning of the gesture. Played at their + own pace meanwhile, a quick swipe is over before it is ever taken in hand — + the page arriving lands at once, and the only thing the finger still does is + cancel it. Ask again for the animations on each move until there are some, + rather than once at `ready`. +- **Under a finger, the keyframes are linear.** The curve belongs to the hand + and is already in the pull: an eased scrub runs ahead of the finger through + the middle of the gesture and lags at the ends, and what one feels is the + page leaving on its own rather than being pushed — "I did not even see it + start". Keep it linear for what plays out after the release too: changing the + curve of an animation that is halfway through moves the picture without + anything having moved. An eased curve is for a travel nobody is holding. +- **Write the keyframes with longhands, never the `animation` shorthand.** The + shorthand also writes `animation-play-state`, so a rule using it resets the + hold above to running and the pictures leave without the finger — the same + symptom as no hold at all, from a rule that looks unrelated. +- **Cancelling is the same movement backwards**, not a second one — a second + transition would capture a picture of the wrong "before". Backwards over the + DISTANCE, not the time: the way in is eased, so at half of its time a travel + has covered ~80% of its distance, and rewound at `-1` the whole visible way + back collapses into the steep end of the curve — a snap, not a return. Walk + the pictures home over how far they visibly are from home, at the travel's + own pace, and hand the rate over with `updatePlaybackRate()` (see "The main + thread lies about a running transition" — both halves of this are traps). +- **Put the state back UNDER the picture before dropping the picture.** When a + cancelled gesture has run the animations back to 0, what is on screen is the + old state; undo the state change, let it render, and only then skip the + transition. The two are identical at the instant they are swapped, so nothing + is seen changing. Dropping the picture first shows the state nobody asked for, + for one frame. +- **The change is a `replace`, not a `push`.** A gesture browses; three swipes + back and forth must not bury the way out of the page under six history + entries. What is aimed at (a tab pressed) is the one that pushes. +- **What must follow the gesture is NAMED, not told.** A trait under a tab row, + a header: give it its own `view-transition-name` and the browser animates it + from where it was to where it is, on the same clock. Nothing measures + anything, and it works for elements outside the box that travels. +- **A browser with no view transitions has no "before"**, so there is nothing to + drag: read the gesture anyway and apply the change on release. Detect it + before `ensureDocumentStartViewTransition()` has installed the polyfill (it + marks itself `isPolyfill`). + +Ways to lose an afternoon on this, all seen: + +- **Never wait for a frame inside the update callback.** The browser has + stopped rendering while it runs — it is waiting for that very promise before + taking its picture — so a `requestAnimationFrame` in there waits for a frame + that cannot come, and the transition hangs with the page frozen. And do not + count microtasks either: how many passes a render takes is the renderer's + own business, and a count that works today under-waits after the next + refactor. The component that swaps the DOM is the only one that knows when + it has — have it say so, and await that. _Reference: `observeRouteRender` in + route.jsx, awaited by `whileRouteRenders` in route_travel.jsx._ +- **Clip on the pseudo-elements, never on your own box** — see "What moves + inside a box stays inside the box" above; a travel between pages is the first + of the two cases described there. +- **A hold is not yours, it is the document's.** Pausing a transition from CSS + means pausing whatever transition is running — there is no way to name "mine", + and naming only your own elements would leave everything else the gesture + carries (a trait under a tab row) playing on its own. So a held transition + MUST be let go of before any other one starts: only one exists per document, + starting a second skips the first, and the second is then born paused with + nobody holding it. It never finishes, its pictures stand over the page, and + the page cannot be touched again. Release at the single place that knows a + transition is about to start — for navi, `holdViewTransition` in + `start_view_transition_polyfill.js`, which every transition it starts goes + through. +- **A press during a transition does not reach a captured element** — captured + means not painted where it stands, so nothing hit-tests to it (see "Keep the + page out of it" above, which is half of the answer). The other half is the + travelling box itself, whose rectangle is legitimately covered by pictures: a + gesture meant to grab what is still moving is caught at the document and + matched against the box's rectangle — otherwise reaching for a page + mid-flight does nothing, and the browser answers the gesture instead (the + page rocks under a travel that is already moving). A wheel costs more than a + press there: a press is one event, a wheel gesture is a stream, and heard on + the box alone it loses every event after the first. + +_Reference: `route_travel.jsx` (whole file), demo +`src/nav/demos/route_travel/route_travel.html`. The full spec of the travel +gesture — who owns it, the wheel reading, the retargeting rules — is +[packages/frontend/navi/docs/drag_to_travel.md](../../../packages/frontend/navi/docs/drag_to_travel.md)._ + ## Which tool for which movement -| What moves | Tool | Why | -| -------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| A track between two positions (slides) | `element.animate()` | Needs to be interruptible, and to report when it is over (`finished`) — a CSS transition has to be watched from outside | -| A wheel gliding to a row | rAF loop + spring | The target moves while it plays; a fixed-duration animation cannot be re-aimed | -| Rows appearing/leaving/reordering | View transitions | The change is a DOM mutation; nothing else animates a row that stops existing | -| A dragged clone | `position: fixed` + popover | The top layer puts it over everything without bidding on z-index | -| A state flip (colour, opacity, frost) | CSS transition | Nothing to interrupt, nothing to await | +| What moves | Tool | Why | +| ---------------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| A track between two positions (slides) | `element.animate()` | Needs to be interruptible, and to report when it is over (`finished`) — a CSS transition has to be watched from outside | +| A wheel gliding to a row | rAF loop + spring | The target moves while it plays; a fixed-duration animation cannot be re-aimed | +| Rows appearing/leaving/reordering | View transitions | The change is a DOM mutation; nothing else animates a row that stops existing | +| A dragged clone | `position: fixed` + popover | The top layer puts it over everything without bidding on z-index | +| Two states only one of which can be mounted (routes) | Scrubbed view transition | The picture of the "before" is the only place the second state exists | +| A state flip (colour, opacity, frost) | CSS transition | Nothing to interrupt, nothing to await | ## First paint @@ -170,3 +426,10 @@ the pixels are still moving. Feelings have numbers too: "each press is felt" is a speed spike in the samples right after the press; "mushy" is its absence. A duration wrong by a factor of two is invisible to the eye but obvious in the number. + +One family of exceptions: anything running on the compositor — the +pseudo-elements of a view transition first of all. There the numbers and the +re-rasterized screenshots BOTH describe the main thread, and both can describe +a movement the screen never played (see "The main thread lies about a running +transition"). For those, verify on a compositor capture: a CDP screencast, or +an eye. diff --git a/packages/frontend/dom/dist/jsenv_dom.js b/packages/frontend/dom/dist/jsenv_dom.js index a97480f156..bbf0e81a7a 100644 --- a/packages/frontend/dom/dist/jsenv_dom.js +++ b/packages/frontend/dom/dist/jsenv_dom.js @@ -7628,1316 +7628,1682 @@ const applyWheelScrollThrough = (element, wheelEvent) => { }); }; -/** - * The element `element` is genuinely `position: absolute`/`fixed` relative - * to: its own nearest positioned ancestor (walking up the DOM tree), or - * `document.documentElement` (the viewport) if none is found. - * - * Also aware of `element` itself being promoted to the top layer: a - * `` actually shown modally (`showModal()`, matches `:modal` — a - * `.show()`'d, non-modal dialog does NOT match and is positioned like any - * other in-flow element instead, walked up normally below), or *any* - * `[popover]` element, always uses the initial containing block (the - * viewport) regardless of its own `position` or DOM ancestry — walking up - * its own parent chain (what the rest of this function does) would give - * the wrong answer for these two specifically, since their real DOM - * position becomes irrelevant to their own containing block the moment - * they're actually promoted. Checked via the `popover` attribute itself, - * not the live `:popover-open` state — unlike ``, a `[popover]` - * element has no "local" mode: it's always top-layer-bound once shown, - * regardless of whether it happens to be open right this moment, so the - * static attribute alone is enough (and correct even when called just - * before `showPopover()` actually runs, when `:popover-open` isn't true - * yet). - * - * `document.documentElement` (not `document.body`, not `null`) is this - * function's own "no real container — use the viewport" sentinel: - * `documentElement` is the actual initial containing block, so the walk - * below stops there without testing its own `position` (there's nothing - * beyond it to fall back to anyway) — unlike the previous version of this - * function, which stopped one level too early, at `document.body`, without - * ever testing *its* `position` either (a `position: relative` body, for - * instance, would have been silently skipped). Returning `documentElement` - * instead of `null` also means no special-casing is needed by callers that - * already compare a resolved container against `document.documentElement` - * (see e.g. visible_rect.js's own `hasRealContainer` check). - */ -const getPositionedParent = (element) => { - const isPromotedToTopLayer = - (element.tagName === "DIALOG" && element.matches(":modal")) || - element.hasAttribute("popover"); - if (isPromotedToTopLayer) { - return document.documentElement; - } - let parent = element.parentElement; - while (parent && parent !== document.documentElement) { - const position = window.getComputedStyle(parent).position; - if ( - position === "relative" || - position === "absolute" || - position === "fixed" - ) { - return parent; - } - parent = parent.parentElement; - } - return document.documentElement; -}; +const installImportMetaCssBuild = (importMeta) => { + const IMPORT_META_CSS_BUILD = "jsenv_import_meta_css_build"; -/** - * Walks `element` and its ancestors (stopping at, but not including, - * `document.documentElement`) looking for the first one whose *computed* - * `position` is `fixed` — i.e. pinned to the viewport, ignoring document - * scroll, regardless of what `element` itself is positioned relative to. - * - * @param {Element} element - * @returns {[left: number, top: number] | null} The fixed ancestor's own - * viewport-relative `getBoundingClientRect()` origin, or `null` if neither - * `element` nor any ancestor is fixed (i.e. `element` genuinely scrolls - * with the document). - */ -const findSelfOrAncestorFixedPosition = (element) => { - let current = element; - while (true) { - const computedStyle = window.getComputedStyle(current); - if (computedStyle.position === "fixed") { - const { left, top } = current.getBoundingClientRect(); - return [left, top]; - } - current = current.parentElement; - if (!current || current === document.documentElement) { - break; - } + if (importMeta.css === IMPORT_META_CSS_BUILD) { + return; } - return null; + + const stylesheetMap = new Map(); + const adopt = (url, value) => { + const stylesheet = new CSSStyleSheet({ baseUrl: importMeta.url }); + stylesheet.replaceSync(value); + stylesheetMap.set(url, stylesheet); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]; + }; + const update = (url, value) => { + stylesheetMap.get(url).replaceSync(value); + }; + const remove = (url) => { + const stylesheet = stylesheetMap.get(url); + document.adoptedStyleSheets = document.adoptedStyleSheets.filter( + (s) => s !== stylesheet, + ); + stylesheetMap.delete(url); + }; + + const currentCssSourceMap = new Map(); + Object.defineProperty(importMeta, "css", { + configurable: true, + get() { + return IMPORT_META_CSS_BUILD; + }, + set([value, url]) { + if (value === undefined) { + if (stylesheetMap.has(url)) { + remove(url); + currentCssSourceMap.delete(url); + } + return; + } + if (!stylesheetMap.has(url)) { + adopt(url, value); + currentCssSourceMap.set(url, value); + } else if (currentCssSourceMap.get(url) !== value) { + update(url, value); + currentCssSourceMap.set(url, value); + } + }, + }); }; /** - * Creates a coordinate system positioner for drag operations. - * - * PURPOSE: - * During a drag gesture, the system tracks mouse movement as "scrollable coordinates" - * relative to the scroll container. This function converts those coordinates into - * the actual CSS transform values needed to visually move an element (or a separate - * elementToMove) to follow the mouse. - * - * PARAMETERS: - * - element: The element being grabbed / tracked for drag detection and auto-scroll. - * - referenceElement: Optional. The element whose coordinate system defines the input space. - * When provided, scrollable coords are relative to its scroll container. - * Defaults to element itself. - * - elementToMove: Optional. A different element to apply the transform to (e.g. a clone - * or a table that moves as a whole when a column is dragged). - * When provided, its offsetParent is used as the positioning context. - * - * THE COORDINATE PIPELINE: - * - * Mouse position - * → scrollable coords (relative to referenceScrollContainer, scroll-independent) - * → positioned coords (relative to elementToMove's offsetParent, for CSS transform) - * - * Two types of offsets bridge these spaces: + * Isolates user interactions to only the specified elements, making everything else non-interactive. * - * 1. POSITION OFFSETS (getPositionOffsets): - * Compensate for the fact that positionedParent and referencePositionedParent - * may differ. For example, if `element` lives inside a and `elementToMove` - * is a full table clone, their offsetParents are different elements. - * This offset is the spatial difference between those two positioned ancestors. - * Called dynamically because parents can move (e.g. overlay elements). + * This creates a controlled interaction environment where only the target elements (and their ancestors) + * can receive user input like clicks, keyboard events, focus, etc. All other DOM elements become + * non-interactive, preventing conflicting or unwanted interactions during critical operations + * like drag gestures, modal dialogs, or complex UI states. * - * 2. SCROLL OFFSETS (getScrollOffsets): - * Account for the scroll position of the relevant scroll container(s). - * The math ensures that at grab time, the transform delta is zero (element - * stays at its visual position), and subsequent mouse movement maps 1:1 - * to transform change. + * The function uses the `inert` attribute to achieve this isolation, applying it strategically + * to parts of the DOM tree while preserving the interactive elements and their ancestor chains. * - * CRITICAL CASE — positionedParent outside referenceScrollContainer: - * When elementToMove's offsetParent is NOT inside the referenceScrollContainer - * (e.g. a clone appended to document.body while tracking an element inside - * an overflow:auto div), the scroll offset must be FROZEN at grab time. - * Using a live scroll value would double-move the clone during auto-scroll: - * the scrollable coordinate decreases (element appears to move up) AND the - * live scroll value increases — both applied to the same transform. - * Freezing the scroll at grab time cancels this out while still correctly - * placing the clone at the right initial position. + * Example DOM structure and inert application: * - * KEY SCENARIOS SUPPORTED: - * 1. Same positioned parent, same scroll container — minimal offsets - * 2. Different positioned parents, same scroll container — position offset compensation - * 3. Same positioned parent, different scroll containers — scroll offset bridging - * 4. Different positioned parents, different containers — full offset compensation - * 5. Overlay elements (data-overlay-for) — specialized offset path - * 6. Fixed positioned elements — special scroll handling - * 7. elementToMove outside referenceScrollContainer — frozen scroll offset at grab + * Before calling isolateInteractions: + * ``` + * + *
...
+ *
+ *
+ * some content + * + * more content + *
+ * + * + *
+ *
...
+ * + * ``` * - * API CONTRACT: - * Returns [scrollableLeft, scrollableTop, convertScrollablePosition] where: + * After calling isolateInteractions([modal, dropdown]): + * ``` + * + *
...
← made inert (no active descendants) + *
← not inert because it contains active elements + *
← not inert because it contains .modal + * some content ← made inert selectively + * ← stays active + * more content ← made inert selectively + *
+ * + * ← stays active + *
+ *
...
+ * + * ``` * - * - scrollableLeft/scrollableTop: - * The element's current position in the reference coordinate system at grab time. - * Used as the layout starting point (layoutScrollableLeft/Top) by the gesture system. + * After calling cleanup(): + * ``` + * + *
...
+ *
+ *
+ * some content + * + * more content + *
+ * ← [inert] preserved + * + *
+ *
...
+ * + * ``` * - * - convertScrollablePosition(scrollableLeft, scrollableTop): - * Converts a scrollable coordinate (from the gesture layout) into a positioned - * coordinate suitable for CSS transform. The gesture system computes: - * topDelta = convertScrollablePosition(layout.scrollableTop) - topAtGrab - * and applies that as translateY. At grab time, delta = 0. As the mouse moves, - * delta tracks the movement exactly, regardless of scroll context differences. + * @param {Array} elements - Array of elements to keep interactive (non-inert) + * @returns {Function} cleanup - Function to restore original inert states */ -const createDragElementPositioner = ( - element, - referenceElement, - elementToMove, -) => { - let scrollableLeft; - let scrollableTop; - let convertScrollablePosition; - - // getPositionedParent, not raw .offsetParent — offsetParent is null for a - // position: fixed element, and also for one promoted to the top layer - // (e.g. a /[popover] being dragged by its own handle), which - // crashes the fixed-position lookup below (findSelfOrAncestorFixedPosition - // assumes a real starting element, not null). getPositionedParent never - // returns null (document.documentElement instead — see its own doc). - const positionedParent = getPositionedParent(elementToMove || element); - const scrollContainer = getScrollContainer(element); - const [getPositionOffsets, getScrollOffsets] = createGetOffsets({ - positionedParent, - referencePositionedParent: referenceElement - ? getPositionedParent(referenceElement) - : positionedParent, - scrollContainer, - referenceScrollContainer: referenceElement - ? getScrollContainer(referenceElement) - : scrollContainer, - }); +const isolateInteractions = (elements) => { + const cleanupCallbackSet = new Set(); + const cleanup = () => { + for (const cleanupCallback of cleanupCallbackSet) { + cleanupCallback(); + } + cleanupCallbackSet.clear(); + }; - { - [scrollableLeft, scrollableTop] = getScrollablePosition( - element, - scrollContainer, - ); - const [positionOffsetLeft, positionOffsetTop] = getPositionOffsets(); - scrollableLeft += positionOffsetLeft; - scrollableTop += positionOffsetTop; - } - { - convertScrollablePosition = ( - scrollableLeftToConvert, - scrollableTopToConvert, - ) => { - const [positionOffsetLeft, positionOffsetTop] = getPositionOffsets(); - const [scrollOffsetLeft, scrollOffsetTop] = getScrollOffsets(); + const toKeepInteractiveSet = new Set(); + const keepSelfAndAncestors = (el) => { + if (toKeepInteractiveSet.has(el)) { + return; + } + const associatedElements = getAssociatedElements(el); + if (associatedElements) { + for (const associatedElement of associatedElements) { + keepSelfAndAncestors(associatedElement); + } + } - const positionedLeftWithoutScroll = - scrollableLeftToConvert + positionOffsetLeft; - const positionedTopWithoutScroll = - scrollableTopToConvert + positionOffsetTop; - const positionedLeft = positionedLeftWithoutScroll + scrollOffsetLeft; - const positionedTop = positionedTopWithoutScroll + scrollOffsetTop; + // Add the element itself + toKeepInteractiveSet.add(el); + // Add all its ancestors up to document.body + let ancestor = el.parentNode; + while (ancestor && ancestor !== document.body) { + toKeepInteractiveSet.add(ancestor); + ancestor = ancestor.parentNode; + } + }; - return [positionedLeft, positionedTop]; - }; + // Build set of elements to keep interactive + for (const element of elements) { + keepSelfAndAncestors(element); } - return [scrollableLeft, scrollableTop, convertScrollablePosition]; -}; - -const getScrollablePosition = (element, scrollContainer) => { - const { left: elementViewportLeft, top: elementViewportTop } = - element.getBoundingClientRect(); - const scrollContainerIsDocument = scrollContainer === documentElement; - if (scrollContainerIsDocument) { - return [elementViewportLeft, elementViewportTop]; + // backdrop elements are meant to control interactions happening at document level + // and should stay interactive + const backdropElements = document.querySelectorAll("[data-backdrop]"); + for (const backdropElement of backdropElements) { + keepSelfAndAncestors(backdropElement); } - const { left: scrollContainerLeft, top: scrollContainerTop } = - scrollContainer.getBoundingClientRect(); - const scrollableLeft = elementViewportLeft - scrollContainerLeft; - const scrollableTop = elementViewportTop - scrollContainerTop; - - return [scrollableLeft, scrollableTop]; -}; -const createGetOffsets = ({ - positionedParent, - referencePositionedParent, - scrollContainer, - referenceScrollContainer, -}) => { - const samePositionedParent = positionedParent === referencePositionedParent; - const getScrollOffsets = createGetScrollOffsets( - scrollContainer, - referenceScrollContainer, - positionedParent, - samePositionedParent, - ); + const setInert = (el) => { + if (toKeepInteractiveSet.has(el)) { + // element should stay interactive + return; + } + const restoreAttributes = setAttributes(el, { + inert: "", + }); + cleanupCallbackSet.add(() => { + restoreAttributes(); + }); + }; - if (samePositionedParent) { - return [() => [0, 0], getScrollOffsets]; - } + const makeElementInertSelectivelyOrCompletely = (el) => { + // If this element should stay interactive, keep it active + if (toKeepInteractiveSet.has(el)) { + return; + } - // parents are different, oh boy let's go - // The overlay case is problematic because the overlay adjust its position to the target dynamically - // This creates something complex to support properly. - // When overlay is fixed we there will never be any offset - // When overlay is absolute there is a diff relative to the scroll - // and eventually if the overlay is positioned differently than the other parent - if (isOverlayOf(positionedParent, referencePositionedParent)) { - return createGetOffsetsForOverlay( - positionedParent, - referencePositionedParent, - { - scrollContainer, - referenceScrollContainer, - getScrollOffsets, - }, - ); - } - if (isOverlayOf(referencePositionedParent, positionedParent)) { - return createGetOffsetsForOverlay( - referencePositionedParent, - positionedParent, - { - scrollContainer, - referenceScrollContainer, - getScrollOffsets, - }, + // Since we put all ancestors in toKeepInteractiveSet, if this element + // is not in the set, we can check if any of its direct children are. + // If none of the direct children are in the set, then no descendants are either. + const children = Array.from(el.children); + const hasInteractiveChildren = children.some((child) => + toKeepInteractiveSet.has(child), ); - } - const scrollContainerIsDocument = scrollContainer === documentElement; - if (scrollContainerIsDocument) { - // Document case: getBoundingClientRect already includes document scroll effects - // Add current scroll position to get the static offset - const getPositionOffsetsDocumentScrolling = () => { - const { scrollLeft: documentScrollLeft, scrollTop: documentScrollTop } = - scrollContainer; - const aRect = positionedParent.getBoundingClientRect(); - const bRect = referencePositionedParent.getBoundingClientRect(); - const aLeft = aRect.left; - const aTop = aRect.top; - const bLeft = bRect.left; - const bTop = bRect.top; - const aLeftDocument = documentScrollLeft + aLeft; - const aTopDocument = documentScrollTop + aTop; - const bLeftDocument = documentScrollLeft + bLeft; - const bTopDocument = documentScrollTop + bTop; - const offsetLeft = bLeftDocument - aLeftDocument; - const offsetTop = bTopDocument - aTopDocument; - return [offsetLeft, offsetTop]; - }; - return [getPositionOffsetsDocumentScrolling, getScrollOffsets]; - } - // Custom scroll container case: account for container's position and scroll - const getPositionOffsetsCustomScrollContainer = () => { - const aRect = positionedParent.getBoundingClientRect(); - const bRect = referencePositionedParent.getBoundingClientRect(); - const aLeft = aRect.left; - const aTop = aRect.top; - const bLeft = bRect.left; - const bTop = bRect.top; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const offsetLeft = - bLeft - aLeft + scrollContainer.scrollLeft - scrollContainerRect.left; - const offsetTop = - bTop - aTop + scrollContainer.scrollTop - scrollContainerRect.top; - return [offsetLeft, offsetTop]; - }; - return [getPositionOffsetsCustomScrollContainer, getScrollOffsets]; -}; -const createGetOffsetsForOverlay = ( - overlay, - overlayTarget, - { scrollContainer, referenceScrollContainer, getScrollOffsets }, -) => { - const sameScrollContainer = scrollContainer === referenceScrollContainer; - const scrollContainerIsDocument = - scrollContainer === document.documentElement; - const referenceScrollContainerIsDocument = - referenceScrollContainer === documentElement; + if (!hasInteractiveChildren) { + // No interactive descendants, make the entire element inert + setInert(el); + return; + } - if (getComputedStyle(overlay).position === "fixed") { - if (referenceScrollContainerIsDocument) { - const getPositionOffsetsFixedOverlay = () => { - return [0, 0]; - }; - return [getPositionOffsetsFixedOverlay, getScrollOffsets]; + // Some children need to stay interactive, process them selectively + for (const child of children) { + makeElementInertSelectivelyOrCompletely(child); } - const getPositionOffsetsFixedOverlay = () => { - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const referenceScrollContainerRect = - referenceScrollContainer.getBoundingClientRect(); - let offsetLeftBetweenScrollContainers = - referenceScrollContainerRect.left - scrollContainerRect.left; - let offsetTopBetweenScrollContainers = - referenceScrollContainerRect.top - scrollContainerRect.top; - if (scrollContainerIsDocument) { - offsetLeftBetweenScrollContainers -= scrollContainer.scrollLeft; - offsetTopBetweenScrollContainers -= scrollContainer.scrollTop; - } - return [ - -offsetLeftBetweenScrollContainers, - -offsetTopBetweenScrollContainers, - ]; - }; - return [getPositionOffsetsFixedOverlay, getScrollOffsets]; + }; + + // Apply inert to all top-level elements that aren't in our keep-interactive set + const bodyChildren = Array.from(document.body.children); + for (const child of bodyChildren) { + makeElementInertSelectivelyOrCompletely(child); } - const getPositionOffsetsOverlay = () => { - if (sameScrollContainer) { - const overlayRect = overlay.getBoundingClientRect(); - const overlayTargetRect = overlayTarget.getBoundingClientRect(); - const overlayLeft = overlayRect.left; - const overlayTop = overlayRect.top; - let overlayTargetLeft = overlayTargetRect.left; - let overlayTargetTop = overlayTargetRect.top; - if (scrollContainerIsDocument) { - overlayTargetLeft += scrollContainer.scrollLeft; - overlayTargetTop += scrollContainer.scrollTop; - } - const offsetLeftBetweenTargetAndOverlay = overlayTargetLeft - overlayLeft; - const offsetTopBetweenTargetAndOverlay = overlayTargetTop - overlayTop; - return [ - -scrollContainer.scrollLeft + offsetLeftBetweenTargetAndOverlay, - -scrollContainer.scrollTop + offsetTopBetweenTargetAndOverlay, - ]; - } + return () => { + cleanup(); + }; +}; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const referenceScrollContainerRect = - referenceScrollContainer.getBoundingClientRect(); - let scrollContainerLeft = scrollContainerRect.left; - let scrollContainerTop = scrollContainerRect.top; - let referenceScrollContainerLeft = referenceScrollContainerRect.left; - let referenceScrollContainerTop = referenceScrollContainerRect.top; - if (scrollContainerIsDocument) { - scrollContainerLeft += scrollContainer.scrollLeft; - scrollContainerTop += scrollContainer.scrollTop; - } - const offsetLeftBetweenScrollContainers = - referenceScrollContainerLeft - scrollContainerLeft; - const offsetTopBetweenScrollContainers = - referenceScrollContainerTop - scrollContainerTop; - return [ - -offsetLeftBetweenScrollContainers - referenceScrollContainer.scrollLeft, - -offsetTopBetweenScrollContainers - referenceScrollContainer.scrollTop, - ]; +installImportMetaCssBuild(import.meta);/** + * Drag Gesture System + * + * TODO: rename moveX/moveY en juste x/y + * puisque move c'est perturbant sachant que c'est drag + scroll + * et que drag c'est juste la partie mouvement de la souris + * + * donc juste x/y ca seras surement mieux + * + */ +const css$5 = /* css */` + .navi_drag_gesture_backdrop { + position: fixed; + inset: 0; + /* A finger dragging must not also pan the page under it. The backdrop is + the only element the finger can be over once the gesture is running. */ + touch-action: none; + user-select: none; + } +`; +const createDragGestureController = (options = {}) => { + const { + name, + onGrab, + onDragStart, + onDrag, + onRelease, + threshold = 5, + direction: defaultDirection = { + x: true, + y: true + }, + documentInteractions = "auto", + backdrop = true, + backdropZIndex = 999999 + } = options; + const dragGestureController = { + grab: null, + gravViaPointer: null }; - const getScrollOffsetsOverlay = () => { - if (sameScrollContainer) { - return [scrollContainer.scrollLeft, scrollContainer.scrollTop]; + const grab = ({ + element, + direction = defaultDirection, + event = new CustomEvent("programmatic"), + grabX = 0, + grabY = 0, + cursor = "grabbing", + scrollContainer = document.documentElement, + layoutScrollableLeft: scrollableLeftAtGrab = 0, + layoutScrollableTop: scrollableTopAtGrab = 0 + } = {}) => { + if (!element) { + throw new Error("element is required"); } - - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const referenceScrollContainerRect = - referenceScrollContainer.getBoundingClientRect(); - let offsetLeftBetweenScrollContainers = - referenceScrollContainerRect.left - scrollContainerRect.left; - let offsetTopBetweenScrollContainers = - referenceScrollContainerRect.top - scrollContainerRect.top; - if (scrollContainerIsDocument) { - offsetLeftBetweenScrollContainers -= scrollContainer.scrollLeft; - offsetTopBetweenScrollContainers -= scrollContainer.scrollTop; + if (!direction.x && !direction.y) { + return null; } - - return [ - referenceScrollContainer.scrollLeft + offsetLeftBetweenScrollContainers, - referenceScrollContainer.scrollTop + offsetTopBetweenScrollContainers, - ]; - }; - return [getPositionOffsetsOverlay, getScrollOffsetsOverlay]; -}; -const isOverlayOf = (element, potentialTarget) => { - const overlayForAttribute = element.getAttribute("data-overlay-for"); - if (!overlayForAttribute) { - return false; - } - const overlayTarget = document.querySelector(`#${overlayForAttribute}`); - if (!overlayTarget) { - return false; - } - if (overlayTarget === potentialTarget) { - return true; - } - const overlayTargetPositionedParent = getPositionedParent(overlayTarget); - if (overlayTargetPositionedParent === potentialTarget) { - return true; - } - return false; -}; - -const { documentElement } = - typeof document === "object" ? document : { documentElement: null }; - -const createGetScrollOffsets = ( - scrollContainer, - referenceScrollContainer, - positionedParent, - samePositionedParent, -) => { - const getGetScrollOffsetsSameContainer = () => { - const scrollContainerIsDocument = scrollContainer === documentElement; - // I don't really get why we have to add scrollLeft (scrollLeft at grab) - // to properly position the element in this scenario - // It happens since we use translateX to position the element - // Or maybe since something else. In any case it works - const { scrollLeft, scrollTop } = samePositionedParent - ? { scrollLeft: 0, scrollTop: 0 } - : referenceScrollContainer; - if (scrollContainerIsDocument) { - const fixedPosition = findSelfOrAncestorFixedPosition(positionedParent); - if (fixedPosition) { - const getScrollOffsetsFixed = () => { - const leftScrollToAdd = scrollLeft + fixedPosition[0]; - const topScrollToAdd = scrollTop + fixedPosition[1]; - return [leftScrollToAdd, topScrollToAdd]; - }; - return getScrollOffsetsFixed; - } + const [publishBeforeDrag, addBeforeDragCallback] = createPubSub(); + const [publishDrag, addDragCallback] = createPubSub(); + const [publishRelease, addReleaseCallback] = createPubSub(); + if (onDrag) { + addDragCallback(onDrag); } - const positionedParentIsInsideScrollContainer = - referenceScrollContainer === documentElement || - referenceScrollContainer.contains(positionedParent); - if (!positionedParentIsInsideScrollContainer) { - // positionedParent is outside the scroll container (e.g. clone in document.body - // while tracking an element inside a custom scroll container). - // We must add the scroll at grab time as a frozen offset so that: - // - initial topDelta = 0 (clone starts at correct position) - // - auto-scroll doesn't double-move the clone (scroll changes cancel out in layout) - const scrollLeftAtGrab = referenceScrollContainer.scrollLeft; - const scrollTopAtGrab = referenceScrollContainer.scrollTop; - return () => [scrollLeft + scrollLeftAtGrab, scrollTop + scrollTopAtGrab]; + if (onRelease) { + addReleaseCallback(onRelease); } - const getScrollOffsets = () => { - const leftScrollToAdd = scrollLeft + referenceScrollContainer.scrollLeft; - const topScrollToAdd = scrollTop + referenceScrollContainer.scrollTop; - return [leftScrollToAdd, topScrollToAdd]; + const scrollLeftAtGrab = scrollContainer.scrollLeft; + const scrollTopAtGrab = scrollContainer.scrollTop; + const leftAtGrab = scrollLeftAtGrab + scrollableLeftAtGrab; + const topAtGrab = scrollTopAtGrab + scrollableTopAtGrab; + const createLayout = (x, y) => { + const { + scrollLeft, + scrollTop + } = scrollContainer; + const left = scrollableLeftAtGrab + x; + const top = scrollableTopAtGrab + y; + const scrollableLeft = left - scrollLeft; + const scrollableTop = top - scrollTop; + const layoutProps = { + // Raw input coordinates (dragX - grabX + scrollContainer.scrollLeft) + x, + y, + // container scrolls when layout is created + scrollLeft, + scrollTop, + // Position relative to container excluding scrolls + scrollableLeft, + scrollableTop, + // Position relative to container including scrolls + left, + top, + // Delta since grab (number representing how much we dragged) + xDelta: left - leftAtGrab, + yDelta: top - topAtGrab + }; + return layoutProps; }; - return getScrollOffsets; - }; + const grabLayout = createLayout(grabX + scrollContainer.scrollLeft, grabY + scrollContainer.scrollTop); + const gestureInfo = { + name, + direction, + started: !threshold, + status: "grabbed", + element, + scrollContainer, + grabX, + // x grab coordinate (excluding scroll) + grabY, + // y grab coordinate (excluding scroll) + grabLayout, + leftAtGrab, + topAtGrab, + dragX: grabX, + // coordinate of the last drag (excluding scroll of the scrollContainer) + dragY: grabY, + // coordinate of the last drag (excluding scroll of the scrollContainer) + layout: grabLayout, + isGoingUp: undefined, + isGoingDown: undefined, + isGoingLeft: undefined, + isGoingRight: undefined, + intentGoingUp: false, + intentGoingDown: false, + intentGoingLeft: false, + intentGoingRight: false, + // How fast the pointer is going, in px/ms, signed per axis + // (see measureVelocity) + velocityX: 0, + velocityY: 0, + velocity: 0, + // metadata about interaction sources + grabEvent: event, + dragEvent: null, + releaseEvent: null + }; + definePropertyAsReadOnly(gestureInfo, "name"); + definePropertyAsReadOnly(gestureInfo, "direction"); + definePropertyAsReadOnly(gestureInfo, "scrollContainer"); + definePropertyAsReadOnly(gestureInfo, "grabX"); + definePropertyAsReadOnly(gestureInfo, "grabY"); + definePropertyAsReadOnly(gestureInfo, "grabLayout"); + definePropertyAsReadOnly(gestureInfo, "leftAtGrab"); + definePropertyAsReadOnly(gestureInfo, "topAtGrab"); + definePropertyAsReadOnly(gestureInfo, "grabEvent"); - const sameScrollContainer = scrollContainer === referenceScrollContainer; - const getScrollOffsetsSameContainer = getGetScrollOffsetsSameContainer(); - if (sameScrollContainer) { - return getScrollOffsetsSameContainer; - } - const getScrollOffsetsDifferentContainers = () => { - const [scrollLeftToAdd, scrollTopToAdd] = getScrollOffsetsSameContainer(); - const rect = scrollContainer.getBoundingClientRect(); - const referenceRect = referenceScrollContainer.getBoundingClientRect(); - const leftDiff = referenceRect.left - rect.left; - const topDiff = referenceRect.top - rect.top; - return [scrollLeftToAdd + leftDiff, scrollTopToAdd + topDiff]; - }; - return getScrollOffsetsDifferentContainers; -}; -const getDragCoordinates = ( - element, - scrollContainer = getScrollContainer(element), -) => { - const [scrollableLeft, scrollableTop] = getScrollablePosition( - element, - scrollContainer, - ); - const { scrollLeft, scrollTop } = scrollContainer; - const leftRelativeToScrollContainer = scrollableLeft + scrollLeft; - const topRelativeToScrollContainer = scrollableTop + scrollTop; - return [leftRelativeToScrollContainer, topRelativeToScrollContainer]; -}; - -const installImportMetaCssBuild = (importMeta) => { - const IMPORT_META_CSS_BUILD = "jsenv_import_meta_css_build"; + // Where the pointer IS is not where it is going: throwing something is a + // matter of speed, and the gesture is the only place that sees the timing of + // the events it receives. + const measureVelocity = createVelocityMeter(grabX, grabY); + document_interactions: { + if (documentInteractions === "manual") { + break document_interactions; + } + /* + GOAL: Take control of document-level interactions during drag gestures + + WHY: During drag operations, we need to prevent conflicting user interactions that would: + 1. Interfere with the drag gesture (competing pointer events, focus changes) + 2. Break the visual feedback (inconsistent cursors, hover states) + 3. Cause unwanted scrolling (keyboard shortcuts, wheel events in restricted directions) + 4. Create accessibility issues (focus jumping, screen reader confusion) + STRATEGY: Create a controlled interaction environment by: + 1. VISUAL CONTROL: Use a backdrop to unify cursor appearance and block pointer events + 2. INTERACTION ISOLATION: Make non-dragged elements inert to prevent interference + 3. FOCUS MANAGEMENT: Control focus location and prevent focus changes during drag + 4. SELECTIVE SCROLLING: Allow scrolling only in directions supported by the drag gesture + IMPLEMENTATION: + */ - if (importMeta.css === IMPORT_META_CSS_BUILD) { - return; - } + // 1. INTERACTION ISOLATION: Make everything except the dragged element inert + // This prevents keyboard events, pointer interactions, and screen reader navigation + // on non-relevant elements during the drag operation + const cleanupInert = isolateInteractions([element, ...Array.from(document.querySelectorAll("[data-droppable]"))]); + addReleaseCallback(() => { + cleanupInert(); + }); - const stylesheetMap = new Map(); - const adopt = (url, value) => { - const stylesheet = new CSSStyleSheet({ baseUrl: importMeta.url }); - stylesheet.replaceSync(value); - stylesheetMap.set(url, stylesheet); - document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]; - }; - const update = (url, value) => { - stylesheetMap.get(url).replaceSync(value); - }; - const remove = (url) => { - const stylesheet = stylesheetMap.get(url); - document.adoptedStyleSheets = document.adoptedStyleSheets.filter( - (s) => s !== stylesheet, - ); - stylesheetMap.delete(url); - }; + // 2. VISUAL CONTROL: Backdrop for consistent cursor and pointer event blocking + if (backdrop) { + import.meta.css = [css$5, "@jsenv/dom/src/interaction/drag/drag_gesture.js"]; + const backdropElement = document.createElement("div"); + backdropElement.className = "navi_drag_gesture_backdrop"; + backdropElement.ariaHidden = "true"; + backdropElement.setAttribute("data-backdrop", ""); + backdropElement.style.zIndex = backdropZIndex; + backdropElement.style.cursor = cursor; - const currentCssSourceMap = new Map(); - Object.defineProperty(importMeta, "css", { - configurable: true, - get() { - return IMPORT_META_CSS_BUILD; - }, - set([value, url]) { - if (value === undefined) { - if (stylesheetMap.has(url)) { - remove(url); - currentCssSourceMap.delete(url); + // Handle wheel events on backdrop for directionally-constrained drag gestures + // (e.g., table column resize should only allow horizontal scrolling) + if (!direction.x || !direction.y) { + backdropElement.onwheel = e => { + e.preventDefault(); + const scrollX = direction.x ? e.deltaX : 0; + const scrollY = direction.y ? e.deltaY : 0; + scrollContainer.scrollBy({ + left: scrollX, + top: scrollY, + behavior: "auto" + }); + }; } - return; - } - if (!stylesheetMap.has(url)) { - adopt(url, value); - currentCssSourceMap.set(url, value); - } else if (currentCssSourceMap.get(url) !== value) { - update(url, value); - currentCssSourceMap.set(url, value); + document.body.appendChild(backdropElement); + addReleaseCallback(() => { + backdropElement.remove(); + }); } - }, - }); -}; -/** - * Isolates user interactions to only the specified elements, making everything else non-interactive. - * - * This creates a controlled interaction environment where only the target elements (and their ancestors) - * can receive user input like clicks, keyboard events, focus, etc. All other DOM elements become - * non-interactive, preventing conflicting or unwanted interactions during critical operations - * like drag gestures, modal dialogs, or complex UI states. - * - * The function uses the `inert` attribute to achieve this isolation, applying it strategically - * to parts of the DOM tree while preserving the interactive elements and their ancestor chains. - * - * Example DOM structure and inert application: - * - * Before calling isolateInteractions: - * ``` - * - *
...
- *
- *
- * some content - * - * more content - *
- * - * - *
- *
...
- * - * ``` - * - * After calling isolateInteractions([modal, dropdown]): - * ``` - * - *
...
← made inert (no active descendants) - *
← not inert because it contains active elements - *
← not inert because it contains .modal - * some content ← made inert selectively - * ← stays active - * more content ← made inert selectively - *
- * - * ← stays active - *
- *
...
- * - * ``` - * - * After calling cleanup(): - * ``` - * - *
...
- *
- *
- * some content - * - * more content - *
- * ← [inert] preserved - * - *
- *
...
- * - * ``` - * - * @param {Array} elements - Array of elements to keep interactive (non-inert) - * @returns {Function} cleanup - Function to restore original inert states - */ -const isolateInteractions = (elements) => { - const cleanupCallbackSet = new Set(); - const cleanup = () => { - for (const cleanupCallback of cleanupCallbackSet) { - cleanupCallback(); - } - cleanupCallbackSet.clear(); - }; + // 3. FOCUS MANAGEMENT: Control and stabilize focus during drag + const { + activeElement + } = document; + const focusableElement = findFocusable(element); + // Focus the dragged element (or document.body as fallback) to establish clear focus context + // This also ensure any keydown event listened by the currently focused element + // won't be available during drag + const elementToFocus = focusableElement || document.body; + elementToFocus.focus({ + preventScroll: true + }); + addReleaseCallback(() => { + // Restore original focus on release + activeElement.focus({ + preventScroll: true + }); + }); + // Prevent Tab navigation entirely (focus should stay stable) + const onkeydown = e => { + if (e.key === "Tab") { + e.preventDefault(); + return; + } + }; + document.addEventListener("keydown", onkeydown); + addReleaseCallback(() => { + document.removeEventListener("keydown", onkeydown); + }); - const toKeepInteractiveSet = new Set(); - const keepSelfAndAncestors = (el) => { - if (toKeepInteractiveSet.has(el)) { - return; - } - const associatedElements = getAssociatedElements(el); - if (associatedElements) { - for (const associatedElement of associatedElements) { - keepSelfAndAncestors(associatedElement); + // 4. SELECTIVE SCROLLING: Allow keyboard scrolling only in supported directions + { + const onDocumentKeydown = keyboardEvent => { + // Vertical scrolling keys - prevent if vertical movement not supported + if (keyboardEvent.key === "ArrowUp" || keyboardEvent.key === "ArrowDown" || keyboardEvent.key === " " || keyboardEvent.key === "PageUp" || keyboardEvent.key === "PageDown" || keyboardEvent.key === "Home" || keyboardEvent.key === "End") { + if (!direction.y) { + keyboardEvent.preventDefault(); + } + return; + } + // Horizontal scrolling keys - prevent if horizontal movement not supported + if (keyboardEvent.key === "ArrowLeft" || keyboardEvent.key === "ArrowRight") { + if (!direction.x) { + keyboardEvent.preventDefault(); + } + return; + } + }; + document.addEventListener("keydown", onDocumentKeydown); + addReleaseCallback(() => { + document.removeEventListener("keydown", onDocumentKeydown); + }); } } - // Add the element itself - toKeepInteractiveSet.add(el); - // Add all its ancestors up to document.body - let ancestor = el.parentNode; - while (ancestor && ancestor !== document.body) { - toKeepInteractiveSet.add(ancestor); - ancestor = ancestor.parentNode; + // Set up scroll event handling to adjust drag position when scrolling occurs + { + let isHandlingScroll = false; + const handleScroll = scrollEvent => { + if (isHandlingScroll) { + return; + } + isHandlingScroll = true; + drag(gestureInfo.dragX, gestureInfo.dragY, { + event: scrollEvent + }); + isHandlingScroll = false; + }; + const scrollEventReceiver = scrollContainer === document.documentElement ? document : scrollContainer; + scrollEventReceiver.addEventListener("scroll", handleScroll, { + passive: true + }); + addReleaseCallback(() => { + scrollEventReceiver.removeEventListener("scroll", handleScroll, { + passive: true + }); + }); } - }; - - // Build set of elements to keep interactive - for (const element of elements) { - keepSelfAndAncestors(element); - } - // backdrop elements are meant to control interactions happening at document level - // and should stay interactive - const backdropElements = document.querySelectorAll("[data-backdrop]"); - for (const backdropElement of backdropElements) { - keepSelfAndAncestors(backdropElement); - } + const determineDragData = ({ + dragX, + dragY, + dragEvent, + isRelease = false + }) => { + // === ÉTAT INITIAL (au moment du grab) === + const { + grabX, + grabY, + grabLayout + } = gestureInfo; + // === CE QUI EST DEMANDÉ (où on veut aller) === + // Calcul de la direction basé sur le mouvement précédent + // (ne tient pas compte du mouvement final une fois les contraintes appliquées) + // (ici on veut connaitre l'intention) + // on va utiliser cela pour savoir vers où on scroll si nécéssaire par ex + const currentDragX = gestureInfo.dragX; + const currentDragY = gestureInfo.dragY; + const isGoingLeft = dragX < currentDragX; + const isGoingRight = dragX > currentDragX; + const isGoingUp = dragY < currentDragY; + const isGoingDown = dragY > currentDragY; + const layoutXRequested = direction.x ? scrollContainer.scrollLeft + (dragX - grabX) : grabLayout.scrollLeft; + const layoutYRequested = direction.y ? scrollContainer.scrollTop + (dragY - grabY) : grabLayout.scrollTop; + const layoutRequested = createLayout(layoutXRequested, layoutYRequested); + const currentLayout = gestureInfo.layout; + let layout; + if (layoutRequested.x === currentLayout.x && layoutRequested.y === currentLayout.y) { + layout = currentLayout; + } else { + // === APPLICATION DES CONTRAINTES === + let layoutConstrained = layoutRequested; + const limitLayout = (left, top) => { + layoutConstrained = createLayout(left === undefined ? layoutConstrained.x : left - scrollableLeftAtGrab, top === undefined ? layoutConstrained.y : top - scrollableTopAtGrab); + }; + publishBeforeDrag(layoutRequested, currentLayout, limitLayout, { + dragEvent, + isRelease + }); + // === ÉTAT FINAL === + layout = layoutConstrained; + } + const dragData = { + dragX, + dragY, + layout, + isGoingLeft, + isGoingRight, + isGoingUp, + isGoingDown, + status: isRelease ? "released" : "dragging", + dragEvent: isRelease ? gestureInfo.dragEvent : dragEvent, + releaseEvent: isRelease ? dragEvent : null + }; + if (isRelease) { + return dragData; + } + if (!gestureInfo.started && threshold) { + const deltaX = Math.abs(dragX - grabX); + const deltaY = Math.abs(dragY - grabY); + if (direction.x && direction.y) { + // Both directions: check both axes + if (deltaX < threshold && deltaY < threshold) { + return dragData; + } + } else if (direction.x) { + if (deltaX < threshold) { + return dragData; + } + } else if (direction.y) { + if (deltaY < threshold) { + return dragData; + } + } + dragData.started = true; + } + return dragData; + }; + const markAsStarted = () => { + // Suppress the click that the browser fires after pointerup following a real drag. + // The capture phase runs before any element onClick handler. + const suppressClick = clickEvent => { + clickEvent.stopPropagation(); + clickEvent.preventDefault(); + stopSuppressingClick(); + }; + // That click is dispatched AFTER the pointerup that ends the drag, so + // this cannot be taken down with the gesture — it would be gone one event + // too early, and the drag would end on the link it started from being + // followed. It goes once it has swallowed the click, or at the next press + // if the drag produced none: a click is always preceded by a press, so a + // suppressor that outlives one press can never reach the click of + // another. + const stopSuppressingClick = () => { + document.removeEventListener("click", suppressClick, { + capture: true + }); + document.removeEventListener("pointerdown", stopSuppressingClick, { + capture: true + }); + }; + document.addEventListener("click", suppressClick, { + capture: true + }); + addReleaseCallback(() => { + document.addEventListener("pointerdown", stopSuppressingClick, { + capture: true + }); + }); + // Everything this gesture puts on the document is in place, and undoable, + // BEFORE anybody is told it started: a listener may end the gesture from + // inside this very notification — that is how a press becomes a drag (see + // dragAfterIntent, where the gesture that measured the distance releases + // itself the moment it is confirmed). Set up afterwards, a listener would + // be registering its own removal with a gesture that is already over, and + // would then outlive it: what one sees is a click swallowed long after + // the drag it belonged to. + dispatchPublicCustomEvent(element, "navi_drag_start", { + gestureInfo + }); + onDragStart?.(gestureInfo); + }; - const setInert = (el) => { - if (toKeepInteractiveSet.has(el)) { - // element should stay interactive - return; - } - const restoreAttributes = setAttributes(el, { - inert: "", - }); - cleanupCallbackSet.add(() => { - restoreAttributes(); + // Declares the gesture confirmed without waiting for the distance threshold, + // for callers who established the intent some other way (a dedicated handle, + // a long press). + const start = () => { + if (gestureInfo.started) { + return; + } + gestureInfo.started = true; + markAsStarted(); + }; + const drag = (dragX = gestureInfo.dragX, + // Scroll container relative X coordinate + dragY = gestureInfo.dragY, + // Scroll container relative Y coordinate + { + event = new CustomEvent("programmatic"), + isRelease = false + } = {}) => { + const dragData = determineDragData({ + dragX, + dragY, + dragEvent: event, + isRelease + }); + const [velocityX, velocityY] = measureVelocity(dragX, dragY); + const startedPrevious = gestureInfo.started; + const layoutPrevious = gestureInfo.layout; + // previousGestureInfo = { ...gestureInfo }; + Object.assign(gestureInfo, dragData); + gestureInfo.velocityX = velocityX; + gestureInfo.velocityY = velocityY; + gestureInfo.velocity = Math.hypot(velocityX, velocityY); + if (gestureInfo.isGoingDown) { + gestureInfo.intentGoingDown = true; + gestureInfo.intentGoingUp = false; + } else if (gestureInfo.isGoingUp) { + gestureInfo.intentGoingUp = true; + gestureInfo.intentGoingDown = false; + } + if (gestureInfo.isGoingRight) { + gestureInfo.intentGoingRight = true; + gestureInfo.intentGoingLeft = false; + } else if (gestureInfo.isGoingLeft) { + gestureInfo.intentGoingLeft = true; + gestureInfo.intentGoingRight = false; + } + if (!startedPrevious && gestureInfo.started) { + markAsStarted(); + } + const someLayoutChange = gestureInfo.layout !== layoutPrevious; + dispatchPublicCustomEvent(element, "navi_drag", { + gestureInfo, + someLayoutChange + }); + publishDrag(gestureInfo, + // we still publish drag event even when unchanged + // because UI might need to adjust when document scrolls + // even if nothing truly changes visually the element + // can decide to stick to the scroll for example + someLayoutChange); + }; + const release = ({ + event = new CustomEvent("programmatic"), + releaseX = gestureInfo.dragX, + releaseY = gestureInfo.dragY + } = {}) => { + drag(releaseX, releaseY, { + event, + isRelease: true + }); + dispatchPublicCustomEvent(element, "navi_drag_release", { + gestureInfo + }); + publishRelease(gestureInfo); + }; + dispatchPublicCustomEvent(element, "navi_drag_grab", { + gestureInfo }); + onGrab?.(gestureInfo); + const dragGesture = { + gestureInfo, + addBeforeDragCallback, + addDragCallback, + addReleaseCallback, + start, + drag, + release + }; + return dragGesture; }; - - const makeElementInertSelectivelyOrCompletely = (el) => { - // If this element should stay interactive, keep it active - if (toKeepInteractiveSet.has(el)) { - return; - } - - // Since we put all ancestors in toKeepInteractiveSet, if this element - // is not in the set, we can check if any of its direct children are. - // If none of the direct children are in the set, then no descendants are either. - const children = Array.from(el.children); - const hasInteractiveChildren = children.some((child) => - toKeepInteractiveSet.has(child), - ); - - if (!hasInteractiveChildren) { - // No interactive descendants, make the entire element inert - setInert(el); - return; + dragGestureController.grab = grab; + const initDragByPointer = (grabEvent, dragOptions, initializer) => { + if (!isPrimaryButtonEvent(grabEvent)) { + return null; } - - // Some children need to stay interactive, process them selectively - for (const child of children) { - makeElementInertSelectivelyOrCompletely(child); + const target = grabEvent.target; + if (!target.closest) { + // target is a text node + return null; } + const mouseEventCoords = mouseEvent => { + const { + clientX, + clientY + } = mouseEvent; + return [clientX, clientY]; + }; + const [grabX, grabY] = mouseEventCoords(grabEvent); + const dragGesture = dragGestureController.grab({ + grabX, + grabY, + event: grabEvent, + ...dragOptions + }); + const dragViaPointer = dragEvent => { + const [mouseDragX, mouseDragY] = mouseEventCoords(dragEvent); + dragGesture.drag(mouseDragX, mouseDragY, { + event: dragEvent + }); + }; + const releaseViaPointer = mouseupEvent => { + const [mouseReleaseX, mouseReleaseY] = mouseEventCoords(mouseupEvent); + dragGesture.release({ + event: mouseupEvent, + releaseX: mouseReleaseX, + releaseY: mouseReleaseY + }); + }; + dragGesture.dragViaPointer = dragViaPointer; + dragGesture.releaseViaPointer = releaseViaPointer; + const cleanup = initializer({ + onMove: dragViaPointer, + onRelease: releaseViaPointer, + gestureInfo: dragGesture.gestureInfo + }); + dragGesture.addReleaseCallback(() => { + cleanup(); + }); + return dragGesture; + }; + const grabViaPointer = (grabEvent, options) => { + if (grabEvent.type === "pointerdown") { + return initDragByPointer(grabEvent, options, ({ + onMove, + onRelease, + gestureInfo + }) => { + // Captured on something that will still be there at the end of the + // gesture: the browser releases the capture when its element leaves the + // document, and a gesture whose own effect replaces the DOM under the + // finger would lose the pointer at its first move. Callers whose target + // is stable have nothing to say and keep it. + const target = options?.pointerCaptureElement || grabEvent.target; + target.setPointerCapture(grabEvent.pointerId); + /* + * A touchmove left alone is the browser deciding the touch belongs to + * it: it takes it to scroll with, and a touch it has taken is a pointer + * stream it CANCELS — the gesture dies mid-move, the finger is still + * down, and nothing reads it anymore. + * + * Refused only once the gesture is established (a `touch-action: none` + * would take the touch from everyone who merely brushes past the + * element), but LISTENED FOR from the grab: whether a touchmove can be + * refused at all is decided when the touch begins, from the listeners + * present at that moment. Registered later, the listener is handed + * events that are already `cancelable: false` — refusing them does + * nothing, and the reason is invisible in the code that refuses. + * + * On the window in capture AND on the grabbed element: a touch keeps + * being dispatched at the node it started on, and a gesture may take + * that node out of the document (a page that travels navigates) — from + * then on the event never passes through the window on its way + * anywhere. + */ + const preventTouchScroll = touchMoveEvent => { + if (gestureInfo.started && touchMoveEvent.cancelable) { + touchMoveEvent.preventDefault(); + } + }; + const grabTarget = grabEvent.target; + window.addEventListener("touchmove", preventTouchScroll, { + passive: false, + capture: true + }); + grabTarget.addEventListener("touchmove", preventTouchScroll, { + passive: false + }); + // Only OUR capture ending means this gesture is over: + // lostpointercapture bubbles, so a descendant giving up its own capture + // walks straight into this listener. That is not a rare shape — it is + // exactly what happens when a gesture hands over to another one (a + // press that becomes a drag releases its intermediate gesture, held on + // the pressed element, while the real one is being held on a container + // above it), and taken as our own it kills the new gesture one + // millisecond after it started. + const onCaptureLost = pointerEvent => { + if (pointerEvent.target !== target) { + return; + } + onRelease(pointerEvent); + }; + target.addEventListener("lostpointercapture", onCaptureLost); + target.addEventListener("pointercancel", onRelease); + target.addEventListener("pointermove", onMove); + target.addEventListener("pointerup", onRelease); + // The end of the pointer is also listened for on the window, because + // the end is the one event a gesture cannot afford to miss and the + // element it is captured on is not always on its way: a pointer can + // be delivered somewhere else entirely (a browser view transition + // sends presses to the document root), and a cancel dispatched there + // never passes through this element. Missed, the gesture never ends — + // whatever it was holding stays held. + let released = false; + const onPointerEnd = pointerEvent => { + if (pointerEvent.pointerId !== grabEvent.pointerId || released) { + return; + } + released = true; + onRelease(pointerEvent); + }; + window.addEventListener("pointerup", onPointerEnd, true); + window.addEventListener("pointercancel", onPointerEnd, true); + return () => { + // Listeners first, capture last: giving the pointer back is the + // one thing here that can throw, and a gesture that fails to clean + // up half way is worse than one that never cleaned up at all — its + // listeners stay on the element and answer the NEXT gesture, from + // a gesture whose pointer is long gone. + window.removeEventListener("touchmove", preventTouchScroll, { + capture: true + }); + grabTarget.removeEventListener("touchmove", preventTouchScroll); + target.removeEventListener("lostpointercapture", onCaptureLost); + target.removeEventListener("pointercancel", onRelease); + target.removeEventListener("pointermove", onMove); + target.removeEventListener("pointerup", onRelease); + window.removeEventListener("pointerup", onPointerEnd, true); + window.removeEventListener("pointercancel", onPointerEnd, true); + // Asked for only while there is something to give back: a pointer + // that is up no longer exists, the browser has already dropped the + // capture with it, and asking again throws ("No active pointer with + // the given id is found") — on the most ordinary release there is. + if (target.hasPointerCapture(grabEvent.pointerId)) { + target.releasePointerCapture(grabEvent.pointerId); + } + }; + }); + } + if (grabEvent.type === "mousedown") { + console.warn(`Received "mousedown" event, "pointerdown" events are recommended to perform drag gestures.`); + return initDragByPointer(grabEvent, options, ({ + onMove, + onRelease + }) => { + const onPointerUp = pointerEvent => { + //
and `elementToMove` + * is a full table clone, their offsetParents are different elements. + * This offset is the spatial difference between those two positioned ancestors. + * Called dynamically because parents can move (e.g. overlay elements). + * + * 2. SCROLL OFFSETS (getScrollOffsets): + * Account for the scroll position of the relevant scroll container(s). + * The math ensures that at grab time, the transform delta is zero (element + * stays at its visual position), and subsequent mouse movement maps 1:1 + * to transform change. + * + * CRITICAL CASE — positionedParent outside referenceScrollContainer: + * When elementToMove's offsetParent is NOT inside the referenceScrollContainer + * (e.g. a clone appended to document.body while tracking an element inside + * an overflow:auto div), the scroll offset must be FROZEN at grab time. + * Using a live scroll value would double-move the clone during auto-scroll: + * the scrollable coordinate decreases (element appears to move up) AND the + * live scroll value increases — both applied to the same transform. + * Freezing the scroll at grab time cancels this out while still correctly + * placing the clone at the right initial position. + * + * KEY SCENARIOS SUPPORTED: + * 1. Same positioned parent, same scroll container — minimal offsets + * 2. Different positioned parents, same scroll container — position offset compensation + * 3. Same positioned parent, different scroll containers — scroll offset bridging + * 4. Different positioned parents, different containers — full offset compensation + * 5. Overlay elements (data-overlay-for) — specialized offset path + * 6. Fixed positioned elements — special scroll handling + * 7. elementToMove outside referenceScrollContainer — frozen scroll offset at grab + * + * API CONTRACT: + * Returns [scrollableLeft, scrollableTop, convertScrollablePosition] where: + * + * - scrollableLeft/scrollableTop: + * The element's current position in the reference coordinate system at grab time. + * Used as the layout starting point (layoutScrollableLeft/Top) by the gesture system. + * + * - convertScrollablePosition(scrollableLeft, scrollableTop): + * Converts a scrollable coordinate (from the gesture layout) into a positioned + * coordinate suitable for CSS transform. The gesture system computes: + * topDelta = convertScrollablePosition(layout.scrollableTop) - topAtGrab + * and applies that as translateY. At grab time, delta = 0. As the mouse moves, + * delta tracks the movement exactly, regardless of scroll context differences. + */ +const createDragElementPositioner = ( + element, + referenceElement, + elementToMove, +) => { + let scrollableLeft; + let scrollableTop; + let convertScrollablePosition; + + // getPositionedParent, not raw .offsetParent — offsetParent is null for a + // position: fixed element, and also for one promoted to the top layer + // (e.g. a /[popover] being dragged by its own handle), which + // crashes the fixed-position lookup below (findSelfOrAncestorFixedPosition + // assumes a real starting element, not null). getPositionedParent never + // returns null (document.documentElement instead — see its own doc). + const positionedParent = getPositionedParent(elementToMove || element); + const scrollContainer = getScrollContainer(element); + const [getPositionOffsets, getScrollOffsets] = createGetOffsets({ + positionedParent, + referencePositionedParent: referenceElement + ? getPositionedParent(referenceElement) + : positionedParent, + scrollContainer, + referenceScrollContainer: referenceElement + ? getScrollContainer(referenceElement) + : scrollContainer, + }); + + { + [scrollableLeft, scrollableTop] = getScrollablePosition( + element, + scrollContainer, + ); + const [positionOffsetLeft, positionOffsetTop] = getPositionOffsets(); + scrollableLeft += positionOffsetLeft; + scrollableTop += positionOffsetTop; + } + { + convertScrollablePosition = ( + scrollableLeftToConvert, + scrollableTopToConvert, + ) => { + const [positionOffsetLeft, positionOffsetTop] = getPositionOffsets(); + const [scrollOffsetLeft, scrollOffsetTop] = getScrollOffsets(); + + const positionedLeftWithoutScroll = + scrollableLeftToConvert + positionOffsetLeft; + const positionedTopWithoutScroll = + scrollableTopToConvert + positionOffsetTop; + const positionedLeft = positionedLeftWithoutScroll + scrollOffsetLeft; + const positionedTop = positionedTopWithoutScroll + scrollOffsetTop; + + return [positionedLeft, positionedTop]; }; - dispatchPublicCustomEvent(element, "navi_drag_grab", { - gestureInfo - }); - onGrab?.(gestureInfo); - const dragGesture = { - gestureInfo, - addBeforeDragCallback, - addDragCallback, - addReleaseCallback, - drag, - release + } + return [scrollableLeft, scrollableTop, convertScrollablePosition]; +}; + +const getScrollablePosition = (element, scrollContainer) => { + const { left: elementViewportLeft, top: elementViewportTop } = + element.getBoundingClientRect(); + const scrollContainerIsDocument = scrollContainer === documentElement; + if (scrollContainerIsDocument) { + return [elementViewportLeft, elementViewportTop]; + } + const { left: scrollContainerLeft, top: scrollContainerTop } = + scrollContainer.getBoundingClientRect(); + const scrollableLeft = elementViewportLeft - scrollContainerLeft; + const scrollableTop = elementViewportTop - scrollContainerTop; + + return [scrollableLeft, scrollableTop]; +}; + +const createGetOffsets = ({ + positionedParent, + referencePositionedParent, + scrollContainer, + referenceScrollContainer, +}) => { + const samePositionedParent = positionedParent === referencePositionedParent; + const getScrollOffsets = createGetScrollOffsets( + scrollContainer, + referenceScrollContainer, + positionedParent, + samePositionedParent, + ); + + if (samePositionedParent) { + return [() => [0, 0], getScrollOffsets]; + } + + // parents are different, oh boy let's go + // The overlay case is problematic because the overlay adjust its position to the target dynamically + // This creates something complex to support properly. + // When overlay is fixed we there will never be any offset + // When overlay is absolute there is a diff relative to the scroll + // and eventually if the overlay is positioned differently than the other parent + if (isOverlayOf(positionedParent, referencePositionedParent)) { + return createGetOffsetsForOverlay( + positionedParent, + referencePositionedParent, + { + scrollContainer, + referenceScrollContainer, + getScrollOffsets, + }, + ); + } + if (isOverlayOf(referencePositionedParent, positionedParent)) { + return createGetOffsetsForOverlay( + referencePositionedParent, + positionedParent, + { + scrollContainer, + referenceScrollContainer, + getScrollOffsets, + }, + ); + } + const scrollContainerIsDocument = scrollContainer === documentElement; + if (scrollContainerIsDocument) { + // Document case: getBoundingClientRect already includes document scroll effects + // Add current scroll position to get the static offset + const getPositionOffsetsDocumentScrolling = () => { + const { scrollLeft: documentScrollLeft, scrollTop: documentScrollTop } = + scrollContainer; + const aRect = positionedParent.getBoundingClientRect(); + const bRect = referencePositionedParent.getBoundingClientRect(); + const aLeft = aRect.left; + const aTop = aRect.top; + const bLeft = bRect.left; + const bTop = bRect.top; + const aLeftDocument = documentScrollLeft + aLeft; + const aTopDocument = documentScrollTop + aTop; + const bLeftDocument = documentScrollLeft + bLeft; + const bTopDocument = documentScrollTop + bTop; + const offsetLeft = bLeftDocument - aLeftDocument; + const offsetTop = bTopDocument - aTopDocument; + return [offsetLeft, offsetTop]; }; - return dragGesture; + return [getPositionOffsetsDocumentScrolling, getScrollOffsets]; + } + // Custom scroll container case: account for container's position and scroll + const getPositionOffsetsCustomScrollContainer = () => { + const aRect = positionedParent.getBoundingClientRect(); + const bRect = referencePositionedParent.getBoundingClientRect(); + const aLeft = aRect.left; + const aTop = aRect.top; + const bLeft = bRect.left; + const bTop = bRect.top; + + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const offsetLeft = + bLeft - aLeft + scrollContainer.scrollLeft - scrollContainerRect.left; + const offsetTop = + bTop - aTop + scrollContainer.scrollTop - scrollContainerRect.top; + return [offsetLeft, offsetTop]; }; - dragGestureController.grab = grab; - const initDragByPointer = (grabEvent, dragOptions, initializer) => { - if (!isPrimaryButtonEvent(grabEvent)) { - return null; - } - const target = grabEvent.target; - if (!target.closest) { - // target is a text node - return null; + return [getPositionOffsetsCustomScrollContainer, getScrollOffsets]; +}; +const createGetOffsetsForOverlay = ( + overlay, + overlayTarget, + { scrollContainer, referenceScrollContainer, getScrollOffsets }, +) => { + const sameScrollContainer = scrollContainer === referenceScrollContainer; + const scrollContainerIsDocument = + scrollContainer === document.documentElement; + const referenceScrollContainerIsDocument = + referenceScrollContainer === documentElement; + + if (getComputedStyle(overlay).position === "fixed") { + if (referenceScrollContainerIsDocument) { + const getPositionOffsetsFixedOverlay = () => { + return [0, 0]; + }; + return [getPositionOffsetsFixedOverlay, getScrollOffsets]; } - const mouseEventCoords = mouseEvent => { - const { - clientX, - clientY - } = mouseEvent; - return [clientX, clientY]; - }; - const [grabX, grabY] = mouseEventCoords(grabEvent); - const dragGesture = dragGestureController.grab({ - grabX, - grabY, - event: grabEvent, - ...dragOptions - }); - const dragViaPointer = dragEvent => { - const [mouseDragX, mouseDragY] = mouseEventCoords(dragEvent); - dragGesture.drag(mouseDragX, mouseDragY, { - event: dragEvent - }); - }; - const releaseViaPointer = mouseupEvent => { - const [mouseReleaseX, mouseReleaseY] = mouseEventCoords(mouseupEvent); - dragGesture.release({ - event: mouseupEvent, - releaseX: mouseReleaseX, - releaseY: mouseReleaseY - }); + const getPositionOffsetsFixedOverlay = () => { + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const referenceScrollContainerRect = + referenceScrollContainer.getBoundingClientRect(); + let offsetLeftBetweenScrollContainers = + referenceScrollContainerRect.left - scrollContainerRect.left; + let offsetTopBetweenScrollContainers = + referenceScrollContainerRect.top - scrollContainerRect.top; + if (scrollContainerIsDocument) { + offsetLeftBetweenScrollContainers -= scrollContainer.scrollLeft; + offsetTopBetweenScrollContainers -= scrollContainer.scrollTop; + } + return [ + -offsetLeftBetweenScrollContainers, + -offsetTopBetweenScrollContainers, + ]; }; - dragGesture.dragViaPointer = dragViaPointer; - dragGesture.releaseViaPointer = releaseViaPointer; - const cleanup = initializer({ - onMove: dragViaPointer, - onRelease: releaseViaPointer - }); - dragGesture.addReleaseCallback(() => { - cleanup(); - }); - return dragGesture; + return [getPositionOffsetsFixedOverlay, getScrollOffsets]; + } + + const getPositionOffsetsOverlay = () => { + if (sameScrollContainer) { + const overlayRect = overlay.getBoundingClientRect(); + const overlayTargetRect = overlayTarget.getBoundingClientRect(); + const overlayLeft = overlayRect.left; + const overlayTop = overlayRect.top; + let overlayTargetLeft = overlayTargetRect.left; + let overlayTargetTop = overlayTargetRect.top; + if (scrollContainerIsDocument) { + overlayTargetLeft += scrollContainer.scrollLeft; + overlayTargetTop += scrollContainer.scrollTop; + } + const offsetLeftBetweenTargetAndOverlay = overlayTargetLeft - overlayLeft; + const offsetTopBetweenTargetAndOverlay = overlayTargetTop - overlayTop; + return [ + -scrollContainer.scrollLeft + offsetLeftBetweenTargetAndOverlay, + -scrollContainer.scrollTop + offsetTopBetweenTargetAndOverlay, + ]; + } + + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const referenceScrollContainerRect = + referenceScrollContainer.getBoundingClientRect(); + let scrollContainerLeft = scrollContainerRect.left; + let scrollContainerTop = scrollContainerRect.top; + let referenceScrollContainerLeft = referenceScrollContainerRect.left; + let referenceScrollContainerTop = referenceScrollContainerRect.top; + if (scrollContainerIsDocument) { + scrollContainerLeft += scrollContainer.scrollLeft; + scrollContainerTop += scrollContainer.scrollTop; + } + const offsetLeftBetweenScrollContainers = + referenceScrollContainerLeft - scrollContainerLeft; + const offsetTopBetweenScrollContainers = + referenceScrollContainerTop - scrollContainerTop; + return [ + -offsetLeftBetweenScrollContainers - referenceScrollContainer.scrollLeft, + -offsetTopBetweenScrollContainers - referenceScrollContainer.scrollTop, + ]; }; - const grabViaPointer = (grabEvent, options) => { - if (grabEvent.type === "pointerdown") { - return initDragByPointer(grabEvent, options, ({ - onMove, - onRelease - }) => { - const target = grabEvent.target; - target.setPointerCapture(grabEvent.pointerId); - target.addEventListener("lostpointercapture", onRelease); - target.addEventListener("pointercancel", onRelease); - target.addEventListener("pointermove", onMove); - target.addEventListener("pointerup", onRelease); - return () => { - target.releasePointerCapture(grabEvent.pointerId); - target.removeEventListener("lostpointercapture", onRelease); - target.removeEventListener("pointercancel", onRelease); - target.removeEventListener("pointermove", onMove); - target.removeEventListener("pointerup", onRelease); - }; - }); + const getScrollOffsetsOverlay = () => { + if (sameScrollContainer) { + return [scrollContainer.scrollLeft, scrollContainer.scrollTop]; } - if (grabEvent.type === "mousedown") { - console.warn(`Received "mousedown" event, "pointerdown" events are recommended to perform drag gestures.`); - return initDragByPointer(grabEvent, options, ({ - onMove, - onRelease - }) => { - const onPointerUp = pointerEvent => { - //