From f204b557411dac4020c9343020c337193f1464a3 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 30 Jun 2026 13:46:32 +0200 Subject: [PATCH] =?UTF-8?q?test(floating):=20exhaustive=20parity=20coverag?= =?UTF-8?q?e=20=E2=80=94=20+2=20bugs=20(getScale=20basis,=20window=20scrol?= =?UTF-8?q?lbar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the dual-system comparison suites to 487 cases (350 synthetic + 137 real- DOM, all green) by implementing the gaps a coverage-enumeration audit (floating-parity-coverage-gaps, 9 agents) found untested. Two more real bugs surfaced and fixed: - rects.ts getRectRelativeToOffsetParent passed the ELEMENT's own scale as the getBoundingClientRect basis; floating-ui always uses the offsetParent's. They diverge on an anisotropically-scaled or bordered offsetParent (getScale's fallback rounds the two boxes differently) → sub-pixel reference shift. Now passes offsetParent as the basis (the anisotropic-scale tests caught it). - rects.ts omitted floating-ui's setLeftRTLScrollbarOffset for a window offsetParent (offsets.x = getWindowScrollBarX(documentElement)). ~0 on a normal LTR document, but non-zero under a left-side scrollbar / writing-mode:vertical-rl → wrong flip decision (the writing-mode test caught it). Also ports the getViewportRect scrollbar-gutter correction + SCROLLBAR_MAX, and the isTopLayer short-circuit in getClippingRect (native popover / modal escape clip). New coverage (synthetic): flip bestFit/fallbackStrategy/crossAxis-alignment/ multi-step, limitShift offset-object/axis-toggles/origin-side, size single-axis shift + symmetric shrink, offset crossAxis under RTL, hide boundary + numeric offsets, arrow padding clamp, the reset loop (bare-true preserve + MAX_RESET_COUNT), padding-object × offsetScale, shift data delta. New coverage (real-DOM): document scrollbar, reference-clip ≠ floating-clip (altBoundary), anisotropic + scaled-and-scrolled offsetParent, scaled clipping ancestor, multi-element collisionBoundary, different offsetParents, non-default flip options, modal , body-as-scroller, nested table chain, transformed scrolled, thick asymmetric border, writing-mode vertical-rl, shadow crossing, floating in two scrollers, fixed×scaled sweep. Documented as deliberate non-features (out of the frozen consumer API): derivable function options, virtual-anchor contextElement, rootBoundary:'document', visualViewport pinch-zoom, deep cross-iframe. Co-Authored-By: Claude Opus 4.8 --- src/uix/soma/layers/floating/CONTINUE.md | 81 +- .../soma/layers/floating/engine/clipping.ts | 49 +- .../floating/engine/engine-dom.svelte.test.ts | 873 +++++++++++++++++- .../layers/floating/engine/engine.test.ts | 635 +++++++++++++ src/uix/soma/layers/floating/engine/rects.ts | 17 +- 5 files changed, 1632 insertions(+), 23 deletions(-) diff --git a/src/uix/soma/layers/floating/CONTINUE.md b/src/uix/soma/layers/floating/CONTINUE.md index d82eaa004..4f5688150 100644 --- a/src/uix/soma/layers/floating/CONTINUE.md +++ b/src/uix/soma/layers/floating/CONTINUE.md @@ -137,27 +137,40 @@ engine.test.ts✅ 13-case parity guard: runMiddleware ↔ @floating-ui/core, ide - `floating.svelte.ts` selects own/fui factories via a loosely-typed factory set + branches `autoUpdate`. `@floating-ui` stays the default for A/B (P4 deletes it). -## Verification done — comparison suites + 9 bugs found & fixed +## Verification done — comparison suites + 11 bugs found & fixed Two parity suites assert **own engine == `@floating-ui`** API output (no removal, no -migration; flag stays OFF). Together they found 9 real bugs (4 critical, in the P1 -DOM-read layer); all fixed and re-verified. +migration; flag stays OFF). They found 11 real bugs total (4 critical, in the P1 +DOM-read layer); all fixed and re-verified. **487 comparison cases, all green.** -- **Math/middleware — `engine/engine.test.ts` (232 cases, green).** `runMiddleware` +- **Math/middleware — `engine/engine.test.ts` (350 cases, green).** `runMiddleware` (the real loop) vs `@floating-ui/core`'s `computePosition`, both fed identical - synthetic rects (a synthetic floating-ui `platform` mirrors `snapshotOf`). Full matrix: - 12 placements × 7 edge positions (full chain) + flip-at-edges + shift (main/cross × - limiter × edges) + arrow (+ tiny-ref alignmentOffset) + size (each side ± shift) + - hide (across clip) + extremes (oversized floating/ref, zero-size, fractional, negative - padding, huge offset) + scroll/scale/rtl variants. Pixel-identical. -- **Real-DOM/full-pipeline — `engine/engine-dom.svelte.test.ts` (60 cases, green; - runs in the `client` chromium project).** BOTH systems' `computePosition` on the SAME - real elements — this is the ONLY suite that exercises `rects.ts` + `clipping.ts` - (the synthetic suite injects rects). Limit scenarios: all placements, viewport-edge - flip/shift, nested scroll container, transformed ancestor, **CSS-scaled offsetParent**, - fixed strategy, scrolled page, individual-transform (`translate`/`scale`/`rotate`) - containing block, static-table-cell offsetParent, **`position:fixed` escaping a scroll - container**, arrow + size. + synthetic rects (a synthetic floating-ui `platform` mirrors `snapshotOf`). Covers every + middleware branch: 12 placements × 7 edge positions (full chain); flip (edges, + fallbackPlacements, fallbackAxisSideDirection, crossAxis true/false/alignment, + fallbackStrategy bestFit/initialPlacement, flipAlignment, multi-step oscillation, the + bestFit "nothing fits" reduce); shift (main/cross × limitShift incl. offset object / + axis toggles / origin-side); offset (number/negative/crossAxis/alignmentAxis, RTL + mirror, the post-flip re-run); arrow (centering, tiny-ref alignmentOffset, the + largestPossiblePadding clamp); size (each side, aligned ± rtl, single-axis shift, + noShift shrink); hide (referenceHidden + escaped + numeric offsets + exact boundary); + the reset loop (bare-true preserves coords, MAX_RESET_COUNT cap, placement reset); + per-side padding × offsetScale; extremes (oversized/zero/fractional/neg-padding). +- **Real-DOM/full-pipeline — `engine/engine-dom.svelte.test.ts` (137 cases, green; + `client` chromium project).** BOTH systems' `computePosition` on the SAME real elements + — the ONLY suite exercising `rects.ts` + `clipping.ts` (synthetic injects rects). Limit + scenarios: all placements; viewport-edge flip/shift; single + deeply-nested scroll + containers; floating inside 2 scrollers; transformed / **CSS-scaled** (isotropic + + **anisotropic**) / **scaled-and-scrolled** offsetParent; **scaled clipping ancestor**; + fixed strategy (+ fixed×scaled sweep); scrolled page; **transformed `` on a + scrolled page**; individual-transform containing block; static-table-cell + **nested + table chain**; `position:fixed` escaping a scroll container; native-popover + modal- + `` top-layer clip escape; **reference clip ≠ floating clip (altBoundary)**; + multi-element collisionBoundary; **reference & floating under different offsetParents**; + virtual `Measurable` anchor; **RTL** + **writing-mode vertical-rl**; shadow DOM + (same-level + crossing); sticky ancestor; visible scrollbar; **document scrollbar / + body-as-scroller**; thick asymmetric offsetParent border; body/html transform; body + margin; arrow (all placements) + size. **Bugs found & fixed (all confirmed independently by the adversarial audit workflow `floating-engine-parity-audit`, 25 agents):** @@ -186,8 +199,38 @@ DOM-read layer); all fixed and re-verified. 9. **`getOverflowAncestors` (LOW)** — include `win.visualViewport` so `auto-update` binds zoom listeners (filtered out of the clip math). -Audit also classified the documented simplifications (arrow clientSize, size no-reset, -single-document iframe/clipping, the `handle-scroll` non-emit) as intentional — not bugs. +The **coverage-gap audit** (`floating-parity-coverage-gaps`, 9 agents — enumerates every +floating-ui branch NOT yet tested, then I implemented the tractable ones) drove the test +counts to 487 and surfaced 2 MORE real bugs: +10. **`getScale` basis (MED)** — `getRectRelativeToOffsetParent` passed the element's own + scale instead of the offsetParent's (floating-ui's 4th arg is always the offsetParent). + They differ on an anisotropically-scaled or bordered offsetParent (getScale's fallback + rounds the two boxes differently) → a sub-pixel reference shift. Fixed: always pass + `offsetParent` as the scale basis. +11. **window-offsetParent scrollbar offset (MED)** — getRect omitted floating-ui's + `setLeftRTLScrollbarOffset` (`offsets.x = getWindowScrollBarX(documentElement)`) for a + window offsetParent. ~0 on a normal LTR doc (so it passed for a year) but non-zero on a + left-side scrollbar / `writing-mode: vertical-rl` → wrong flip decision. Also added + floating-ui's `getViewportRect` scrollbar-gutter correction + the `SCROLLBAR_MAX` cap + + `isTopLayer` short-circuit in `getClippingRect`. + +Audit classified the documented simplifications as intentional — not bugs: arrow clientSize +(`rects.floating` vs `offsetParent.clientWidth` — identical for the borderless wrapper), +size no-reset, single-document iframe/clipping, the `handle-scroll` non-emit. + +**Deliberate non-features (out of the frozen consumer API — NOT bugs, do not "fix"):** +- **Derivable (function) middleware options** — floating-ui resolves `offset((state)=>…)` + via `evaluate`; our factories take static values. Every soma call site passes static + options, so this is intentionally unsupported (adding `evaluate` everywhere for an unused + feature would bloat the engine). +- **Virtual-anchor `contextElement` scale unwrap** — floating-ui divides a virtual rect by + its `contextElement`'s scale; the frozen `Measurable` type is `{ getBoundingClientRect }` + with no `contextElement`, so soma's `customAnchor` never carries one. +- **`rootBoundary: 'document'`** (`getDocumentRect`) — `floating.svelte` never sets + `rootBoundary`; only `'viewport'` is reachable. +- **visualViewport pinch-zoom offset** — handled in code (the `isWebKit` gate) but not + scriptable in headless chromium, so unverified by test. +- **Deep cross-iframe** offset accumulation — single-document fidelity (header-documented). ### ⚠️ Preview-tool caveat for the broad browser A/B (read before P2/P3) diff --git a/src/uix/soma/layers/floating/engine/clipping.ts b/src/uix/soma/layers/floating/engine/clipping.ts index 2c678be6a..f8a53fa60 100644 --- a/src/uix/soma/layers/floating/engine/clipping.ts +++ b/src/uix/soma/layers/floating/engine/clipping.ts @@ -11,6 +11,7 @@ import { createCoords, getBoundingClientRect, getScale, + getWindowScrollBarX, isContainingBlock, isWebKit, rectToClientRect @@ -18,10 +19,30 @@ import { const { max, min } = Math +// Most scrollbars are 15–18px; cap the correction so unusual styles can't +// produce a wild value. Matches floating-ui's SCROLLBAR_MAX. +const SCROLLBAR_MAX = 25 + function getComputed(el: Element, win: Window): CSSStyleDeclaration { return win.getComputedStyle(el) } +/** Is the element in the top layer (native `popover` / `` modal)? Such an + * element escapes all scroll-container clipping — only the viewport bounds it. + * Mirrors floating-ui's `isTopLayer`. */ +function isTopLayer(el: Element): boolean { + try { + if (el.matches(':popover-open')) return true + } catch { + // :popover-open unsupported — fall through. + } + try { + return el.matches(':modal') + } catch { + return false + } +} + /** Does the element clip its overflowing content (scroll/clip/hidden)? */ export function isOverflowElement(el: Element, win: Window): boolean { const { overflow, overflowX, overflowY, display } = getComputed(el, win) @@ -139,6 +160,28 @@ function getViewportRect(el: Element, strategy: Strategy, win: Window): Rect { y = vv.offsetTop } } + + // `scrollbar-gutter: stable` + `overflow: hidden` on reserves gutter + // space that html.clientWidth counts but isn't visually usable. Mirror + // floating-ui's correction (a no-op in the common no-gutter case). A left-side + // scrollbar instead widens the usable area by its width. + const windowScrollbarX = getWindowScrollBarX(html, win) + if (windowScrollbarX <= 0) { + const doc = html.ownerDocument + const body = doc.body + const bodyStyles = getComputed(body, win) + const bodyMarginInline = + doc.compatMode === 'CSS1Compat' + ? parseFloat(bodyStyles.marginLeft) + parseFloat(bodyStyles.marginRight) || 0 + : 0 + const clippingStableScrollbarWidth = Math.abs(html.clientWidth - body.clientWidth - bodyMarginInline) + if (clippingStableScrollbarWidth <= SCROLLBAR_MAX) { + width -= clippingStableScrollbarWidth + } + } else if (windowScrollbarX <= SCROLLBAR_MAX) { + width += windowScrollbarX + } + return { width, height, x, y } } @@ -178,7 +221,11 @@ export function getClippingRect( win: Window ): Rect { const ancestors: (Element | 'viewport')[] = - boundary === 'clippingAncestors' ? getClippingElementAncestors(element, win) : [...boundary] + boundary === 'clippingAncestors' + ? isTopLayer(element) + ? [] + : getClippingElementAncestors(element, win) + : [...boundary] ancestors.push('viewport') const first = clipOf(element, ancestors[0], strategy, win) diff --git a/src/uix/soma/layers/floating/engine/engine-dom.svelte.test.ts b/src/uix/soma/layers/floating/engine/engine-dom.svelte.test.ts index 43b488518..5cab41ebb 100644 --- a/src/uix/soma/layers/floating/engine/engine-dom.svelte.test.ts +++ b/src/uix/soma/layers/floating/engine/engine-dom.svelte.test.ts @@ -23,12 +23,15 @@ import { arrow as fuiArrow, computePosition as fuiComputePosition, flip as fuiFlip, + hide as fuiHide, offset as fuiOffset, shift as fuiShift, size as fuiSize, type Middleware as FuiMiddleware } from '@floating-ui/dom' +import { hide as ownHide } from './middleware' import type { Middleware } from './types' +import type { Measurable } from '../types' import type { Placement, Strategy } from '../placement' const dom = createActiveDom() @@ -50,13 +53,17 @@ afterEach(() => { for (const el of trash) el.remove() trash.length = 0 window.scrollTo(0, 0) + document.documentElement.removeAttribute('dir') + document.body.removeAttribute('dir') + document.body.style.cssText = '' + document.documentElement.style.cssText = '' }) // Run both engines on the same elements + equivalent middleware specs. type Built = { own: Middleware[]; fui: FuiMiddleware[] } async function bothPositions( - anchor: HTMLElement, + anchor: Measurable | HTMLElement, floating: HTMLElement, mw: Built, placement: Placement, @@ -386,6 +393,870 @@ describe('real-DOM parity — fixed floating escaping a scroll container (clip d } }) +describe('real-DOM parity — virtual anchor (Measurable)', () => { + for (const placement of ['bottom', 'top-start', 'right', 'left-end'] as Placement[]) { + it(placement, async () => { + // A virtual reference (context-menu pattern): only getBoundingClientRect. + const rect = { x: 380, y: 280, width: 4, height: 4, top: 280, left: 380, right: 384, bottom: 284 } + const virtual: Measurable = { getBoundingClientRect: () => rect as DOMRect } + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(virtual, floating, mw, placement) + assertSame(`virtual ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — RTL document', () => { + for (const placement of ['bottom-start', 'bottom-end', 'top-start', 'right-start'] as Placement[]) { + it(placement, async () => { + document.documentElement.setAttribute('dir', 'rtl') + const anchor = box({ + position: 'absolute', + left: '420px', + top: '300px', + width: '90px', + height: '34px' + }) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8, alignmentAxis: 10 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8, alignmentAxis: 10 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`rtl ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — shadow DOM', () => { + for (const placement of ['bottom', 'top-start', 'right'] as Placement[]) { + it(placement, async () => { + const host = box({ position: 'relative' }) + const root = host.attachShadow({ mode: 'open' }) + const anchor = document.createElement('div') + Object.assign(anchor.style, { + position: 'absolute', + left: '300px', + top: '260px', + width: '90px', + height: '34px' + }) + const floating = document.createElement('div') + Object.assign(floating.style, { position: 'absolute', width: '200px', height: '120px' }) + root.appendChild(anchor) + root.appendChild(floating) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`shadow ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — deeply nested scroll containers', () => { + for (const [s1, s2] of [ + [0, 0], + [80, 40], + [160, 120] + ]) { + it(`outerScroll=${s1} innerScroll=${s2}`, async () => { + const outer = box({ + position: 'absolute', + left: '80px', + top: '80px', + width: '420px', + height: '300px', + overflow: 'auto', + border: '1px solid' + }) + const outerTall = box({ height: '900px', width: '700px', position: 'relative' }, outer) + const inner = box( + { + position: 'absolute', + left: '60px', + top: '120px', + width: '260px', + height: '180px', + overflow: 'auto', + border: '1px solid' + }, + outerTall + ) + const innerTall = box({ height: '600px', width: '500px', position: 'relative' }, inner) + const anchor = box( + { position: 'absolute', left: '40px', top: '220px', width: '90px', height: '34px' }, + innerTall + ) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + outer.scrollTop = s1 + inner.scrollTop = s2 + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 4 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 4 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame(`nested2 ${s1}/${s2}`, own, fui) + }) + } +}) + +describe('real-DOM parity — hide (referenceHidden) in a scroll container', () => { + for (const scroll of [0, 260, 520]) { + it(`scrollTop=${scroll}`, async () => { + const scroller = box({ + position: 'absolute', + left: '120px', + top: '120px', + width: '300px', + height: '200px', + overflow: 'auto', + border: '1px solid' + }) + const tall = box({ height: '900px', width: '500px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '40px', top: '120px', width: '90px', height: '34px' }, + tall + ) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + scroller.scrollTop = scroll + const own = await ownComputePosition(anchor, floating, { + placement: 'bottom', + strategy: 'absolute', + middleware: [ownOffset({ mainAxis: 8 }), ownHide({ strategy: 'referenceHidden' })], + dom + }) + const fui = await fuiComputePosition(anchor, floating, { + placement: 'bottom', + strategy: 'absolute', + middleware: [fuiOffset({ mainAxis: 8 }), fuiHide({ strategy: 'referenceHidden' })] + }) + const oh = (own.middlewareData.hide ?? {}) as { referenceHidden?: boolean } + const fh = (fui.middlewareData.hide ?? {}) as { referenceHidden?: boolean } + expect(!!oh.referenceHidden, `hide@${scroll}`).toBe(!!fh.referenceHidden) + }) + } +}) + +describe('real-DOM parity — arrow across all placements (real layout)', () => { + for (const placement of PLACEMENTS) { + it(placement, async () => { + const anchor = box({ + position: 'absolute', + left: '420px', + top: '320px', + width: '110px', + height: '38px' + }) + const floating = box({ position: 'absolute', width: '220px', height: '150px' }) + const arrowEl = box({ position: 'absolute', width: '14px', height: '14px' }, floating) + const { own, fui } = await bothPositions( + anchor, + floating, + { + own: [ownOffset({ mainAxis: 10 }), ownArrow({ element: arrowEl, padding: 8 })], + fui: [fuiOffset({ mainAxis: 10 }), fuiArrow({ element: arrowEl, padding: 8 })] + }, + placement + ) + assertSame(`arrow ${placement}`, own, fui) + const oa = own.middlewareData.arrow as { x?: number; y?: number } + const fa = fui.middlewareData.arrow as { x?: number; y?: number } + expect(Math.abs((oa.x ?? oa.y ?? 0) - (fa.x ?? fa.y ?? 0)), `arrow center ${placement}`).toBeLessThanOrEqual(TOL) + }) + } +}) + +describe('real-DOM parity — transform on body/html ancestor', () => { + for (const target of ['body', 'html'] as const) { + it(`transform on <${target}>`, async () => { + const el = target === 'body' ? document.body : document.documentElement + el.style.transform = 'translateZ(0)' + const anchor = box({ + position: 'absolute', + left: '380px', + top: '300px', + width: '90px', + height: '34px' + }) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame(`transform-${target}`, own, fui) + }) + } +}) + +describe('real-DOM parity — body margin (non-zero document origin)', () => { + it('body margin 24px', async () => { + document.body.style.margin = '24px' + const anchor = box({ + position: 'absolute', + left: '380px', + top: '300px', + width: '90px', + height: '34px' + }) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame('body-margin', own, fui) + }) +}) + +describe('real-DOM parity — sticky ancestor', () => { + for (const scroll of [0, 100]) { + it(`sticky scrollTop=${scroll}`, async () => { + const scroller = box({ + position: 'absolute', + left: '100px', + top: '100px', + width: '320px', + height: '240px', + overflow: 'auto', + border: '1px solid' + }) + const sticky = box({ position: 'sticky', top: '0px', width: '300px', height: '60px' }, scroller) + const tall = box({ height: '700px', width: '300px' }, scroller) + void tall + const anchor = box( + { position: 'absolute', left: '40px', top: '10px', width: '90px', height: '34px' }, + sticky + ) + const floating = box({ position: 'absolute', width: '180px', height: '110px' }) + scroller.scrollTop = scroll + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame(`sticky ${scroll}`, own, fui) + }) + } +}) + +describe('real-DOM parity — visible scrollbar (clientWidth < offsetWidth)', () => { + it('scroll container with a forced scrollbar', async () => { + const scroller = box({ + position: 'absolute', + left: '120px', + top: '120px', + width: '320px', + height: '240px', + overflow: 'scroll', + border: '2px solid' + }) + const tall = box({ height: '900px', width: '900px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '40px', top: '60px', width: '90px', height: '34px' }, + tall + ) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 4 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 4 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame('scrollbar', own, fui) + }) +}) + +describe('real-DOM parity — explicit collisionBoundary element', () => { + for (const placement of ['bottom', 'right'] as Placement[]) { + it(placement, async () => { + const boundary = box({ + position: 'absolute', + left: '150px', + top: '150px', + width: '380px', + height: '260px', + border: '1px solid' + }) + const anchor = box( + { position: 'absolute', left: '260px', top: '200px', width: '90px', height: '34px' }, + boundary + ) + const floating = box({ position: 'absolute', width: '200px', height: '130px' }) + const mw: Built = { + own: [ + ownOffset({ mainAxis: 8 }), + ownFlip({ boundary: [boundary], altBoundary: true }), + ownShift({ boundary: [boundary], altBoundary: true, padding: 4 }) + ], + fui: [ + fuiOffset({ mainAxis: 8 }), + fuiFlip({ boundary: [boundary], altBoundary: true }), + fuiShift({ boundary: [boundary], altBoundary: true, padding: 4 }) + ] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`boundary ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — top-layer floating (native popover) escapes clipping', () => { + for (const placement of ['bottom', 'top'] as Placement[]) { + it(placement, async () => { + const scroller = box({ + position: 'absolute', + left: '120px', + top: '120px', + width: '300px', + height: '200px', + overflow: 'auto', + border: '1px solid' + }) + const tall = box({ height: '700px', width: '500px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '40px', top: '90px', width: '90px', height: '34px' }, + tall + ) + // Native popover floating element — top layer, so the clipping walk must + // short-circuit (isTopLayer) and bound it to the viewport, not the scroller. + const floating = box({ position: 'fixed', width: '200px', height: '140px' }, scroller) + floating.setAttribute('popover', 'manual') + if (typeof floating.showPopover !== 'function') return // unsupported → skip body + floating.showPopover() + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement, 'fixed') + assertSame(`top-layer ${placement}`, own, fui) + floating.hidePopover() + }) + } +}) + +// ════════ Coverage-gap sweep (real-DOM) ══════════════════════════════════════ + +describe('real-DOM parity — document scrollbar present (viewport width)', () => { + for (const placement of ['right', 'bottom-end', 'top-end'] as Placement[]) { + it(placement, async () => { + const spacer = box({ position: 'absolute', top: '0', left: '0', width: '1px', height: '3000px' }) + void spacer + const anchor = box({ + position: 'absolute', + left: `${document.documentElement.clientWidth - 120}px`, + top: '300px', + width: '90px', + height: '34px' + }) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 0 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 0 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`scrollbar-vp ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — reference clip ≠ floating clip (altBoundary)', () => { + for (const scroll of [0, 240]) { + it(`altBoundary flip scrollTop=${scroll}`, async () => { + const scroller = box({ + position: 'absolute', + left: '120px', + top: '120px', + width: '300px', + height: '200px', + overflow: 'auto', + border: '1px solid' + }) + const tall = box({ height: '900px', width: '500px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '40px', top: '120px', width: '90px', height: '34px' }, + tall + ) + // floating lives in body (its own clip = viewport); altBoundary makes + // flip/shift read the REFERENCE's clip (the scroller). + const floating = box({ position: 'absolute', width: '200px', height: '160px' }) + scroller.scrollTop = scroll + const mw: Built = { + own: [ + ownOffset({ mainAxis: 8 }), + ownFlip({ altBoundary: true }), + ownShift({ altBoundary: true, padding: 4 }) + ], + fui: [ + fuiOffset({ mainAxis: 8 }), + fuiFlip({ altBoundary: true }), + fuiShift({ altBoundary: true, padding: 4 }) + ] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame(`altBoundary ${scroll}`, own, fui) + }) + } +}) + +describe('real-DOM parity — anisotropic scale offsetParent', () => { + for (const placement of ['bottom', 'right', 'top-start', 'bottom-end'] as Placement[]) { + it(placement, async () => { + const wrapper = box({ + position: 'relative', + transform: 'scale(1.4, 0.7)', + transformOrigin: 'top left', + left: '180px', + top: '160px', + width: '420px', + height: '340px', + border: '1px solid' + }) + const anchor = box( + { position: 'absolute', left: '120px', top: '130px', width: '90px', height: '34px' }, + wrapper + ) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }, wrapper) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`aniso ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — scaled AND scrolled offsetParent', () => { + for (const placement of ['bottom', 'right'] as Placement[]) { + it(placement, async () => { + const scroller = box({ + position: 'relative', + transform: 'scale(1.3)', + transformOrigin: 'top left', + overflow: 'auto', + left: '120px', + top: '120px', + width: '300px', + height: '220px', + border: '1px solid' + }) + const tall = box({ height: '700px', width: '700px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '60px', top: '180px', width: '90px', height: '34px' }, + tall + ) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }, scroller) + scroller.scrollTop = 120 + scroller.scrollLeft = 40 + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`scaled+scroll ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — multi-element collisionBoundary', () => { + for (const placement of ['bottom', 'right'] as Placement[]) { + it(placement, async () => { + const a = box({ position: 'absolute', left: '150px', top: '150px', width: '400px', height: '260px', border: '1px solid' }) + const b = box({ position: 'absolute', left: '300px', top: '120px', width: '260px', height: '320px', border: '1px solid' }) + const anchor = box({ position: 'absolute', left: '360px', top: '230px', width: '90px', height: '34px' }) + const floating = box({ position: 'absolute', width: '200px', height: '130px' }) + const mw: Built = { + own: [ + ownOffset({ mainAxis: 8 }), + ownFlip({ boundary: [a, b], altBoundary: true }), + ownShift({ boundary: [a, b], altBoundary: true, padding: 4 }) + ], + fui: [ + fuiOffset({ mainAxis: 8 }), + fuiFlip({ boundary: [a, b], altBoundary: true }), + fuiShift({ boundary: [a, b], altBoundary: true, padding: 4 }) + ] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`multi-boundary ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — reference and floating under DIFFERENT offsetParents', () => { + for (const placement of ['bottom', 'right', 'top-start'] as Placement[]) { + it(placement, async () => { + const wrapperA = box({ + position: 'relative', + transform: 'translateZ(0)', + left: '160px', + top: '120px', + width: '300px', + height: '220px', + border: '1px solid' + }) + const anchor = box( + { position: 'absolute', left: '120px', top: '120px', width: '90px', height: '34px' }, + wrapperA + ) + const wrapperB = box({ + position: 'relative', + transform: 'translateZ(0)', + left: '560px', + top: '380px', + width: '320px', + height: '260px', + border: '1px solid' + }) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }, wrapperB) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`cross-op ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — non-default flip options (real overflow)', () => { + const VARIANTS: { name: string; own: () => Middleware; fui: () => FuiMiddleware }[] = [ + { name: 'crossAxis-alignment', own: () => ownFlip({ crossAxis: 'alignment' }), fui: () => fuiFlip({ crossAxis: 'alignment' }) }, + { name: 'fallbackAxisSideDirection-start', own: () => ownFlip({ fallbackAxisSideDirection: 'start' }), fui: () => fuiFlip({ fallbackAxisSideDirection: 'start' }) }, + { name: 'flipAlignment-false', own: () => ownFlip({ flipAlignment: false }), fui: () => fuiFlip({ flipAlignment: false }) } + ] + for (const v of VARIANTS) { + it(v.name, async () => { + const scroller = box({ + position: 'absolute', + left: '120px', + top: '420px', + width: '300px', + height: '200px', + overflow: 'auto', + border: '1px solid' + }) + const tall = box({ height: '700px', width: '500px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '40px', top: '120px', width: '90px', height: '34px' }, + tall + ) + const floating = box({ position: 'absolute', width: '200px', height: '260px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), v.own(), ownShift({ padding: 4 })], + fui: [fuiOffset({ mainAxis: 8 }), v.fui(), fuiShift({ padding: 4 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom-start') + assertSame(`flipopt ${v.name}`, own, fui) + }) + } +}) + +describe('real-DOM parity — modal top-layer escapes clipping', () => { + for (const placement of ['bottom', 'top'] as Placement[]) { + it(placement, async () => { + const scroller = box({ + position: 'absolute', + left: '120px', + top: '120px', + width: '300px', + height: '200px', + overflow: 'auto', + border: '1px solid' + }) + const tall = box({ height: '700px', width: '500px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '40px', top: '90px', width: '90px', height: '34px' }, + tall + ) + const dialog = document.createElement('dialog') + Object.assign(dialog.style, { width: '200px', height: '140px', margin: '0', padding: '0', border: '0' }) + scroller.appendChild(dialog) + if (typeof dialog.showModal !== 'function') return + dialog.showModal() + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, dialog, mw, placement, 'fixed') + assertSame(`modal ${placement}`, own, fui) + dialog.close() + }) + } +}) + +describe('real-DOM parity — body as scroller (filtered from clip walk)', () => { + it('body overflow:auto', async () => { + document.body.style.cssText = 'overflow:auto;' + const spacer = box({ position: 'absolute', top: '0', left: '0', width: '2000px', height: '2000px' }) + void spacer + const anchor = box({ position: 'absolute', left: '380px', top: '300px', width: '90px', height: '34px' }) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame('body-scroller', own, fui) + }) +}) + +describe('real-DOM parity — nested static table chain (multi-skip)', () => { + it('table→td→table→td offsetParent skip', async () => { + const outer = box({ + position: 'relative', + left: '160px', + top: '160px', + width: '600px', + height: '360px', + border: '1px solid' + }) + const t1 = document.createElement('table') + const tb1 = document.createElement('tbody') + const r1 = document.createElement('tr') + const c1 = document.createElement('td') + c1.style.padding = '20px' + const t2 = document.createElement('table') + const tb2 = document.createElement('tbody') + const r2 = document.createElement('tr') + const c2 = document.createElement('td') + c2.style.padding = '20px' + t1.appendChild(tb1) + tb1.appendChild(r1) + r1.appendChild(c1) + c1.appendChild(t2) + t2.appendChild(tb2) + tb2.appendChild(r2) + r2.appendChild(c2) + outer.appendChild(t1) + const anchor = box( + { position: 'absolute', left: '20px', top: '20px', width: '90px', height: '34px' }, + c2 + ) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }, c2) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame('table-chain', own, fui) + }) +}) + +describe('real-DOM parity — transformed on a scrolled page', () => { + it('html translate + scroll', async () => { + document.documentElement.style.transform = 'translate(30px, 50px)' + const spacer = box({ position: 'absolute', top: '0', left: '0', width: '1px', height: '2500px' }) + void spacer + const anchor = box({ + position: 'absolute', + left: '380px', + top: '1400px', + width: '90px', + height: '34px' + }) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + window.scrollTo(0, 1000) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame('html-transform-scroll', own, fui) + }) +}) + +describe('real-DOM parity — thick asymmetric offsetParent border (scrolled)', () => { + for (const placement of ['bottom', 'right'] as Placement[]) { + it(placement, async () => { + const scroller = box({ + position: 'relative', + left: '120px', + top: '120px', + width: '320px', + height: '240px', + overflow: 'auto', + borderLeft: '20px solid', + borderTop: '8px solid', + borderRight: '2px solid', + borderBottom: '2px solid' + }) + const tall = box({ height: '700px', width: '700px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '60px', top: '180px', width: '90px', height: '34px' }, + tall + ) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }, scroller) + scroller.scrollTop = 120 + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`thick-border ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — writing-mode vertical-rl', () => { + for (const placement of ['bottom-start', 'right'] as Placement[]) { + it(placement, async () => { + document.documentElement.style.writingMode = 'vertical-rl' + const anchor = box({ + position: 'absolute', + left: '420px', + top: '300px', + width: '90px', + height: '34px' + }) + const floating = box({ position: 'absolute', width: '200px', height: '120px' }) + const mw: Built = { + own: [ownOffset({ mainAxis: 8, alignmentAxis: 10 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8, alignmentAxis: 10 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`writing-mode ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — shadow boundary crossing (anchor light, floating shadow)', () => { + for (const placement of ['bottom', 'right'] as Placement[]) { + it(placement, async () => { + const anchor = box({ + position: 'absolute', + left: '380px', + top: '300px', + width: '90px', + height: '34px' + }) + const host = box({ position: 'relative' }) + const root = host.attachShadow({ mode: 'open' }) + const floating = document.createElement('div') + Object.assign(floating.style, { position: 'absolute', width: '200px', height: '120px' }) + root.appendChild(floating) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`shadow-cross ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — floating inside two nested scroll containers', () => { + for (const [s1, s2] of [ + [0, 0], + [100, 60] + ]) { + it(`outer=${s1} inner=${s2}`, async () => { + const outer = box({ + position: 'absolute', + left: '80px', + top: '80px', + width: '440px', + height: '320px', + overflow: 'auto', + border: '1px solid' + }) + const outerTall = box({ height: '900px', width: '760px', position: 'relative' }, outer) + const inner = box( + { + position: 'absolute', + left: '40px', + top: '60px', + width: '300px', + height: '220px', + overflow: 'auto', + border: '1px solid' + }, + outerTall + ) + const innerTall = box({ height: '700px', width: '600px', position: 'relative' }, inner) + const anchor = box( + { position: 'absolute', left: '40px', top: '120px', width: '90px', height: '34px' }, + innerTall + ) + // floating ALSO inside the inner scroller → its clip = inner ∩ outer ∩ vp. + const floating = box({ position: 'absolute', width: '180px', height: '120px' }, innerTall) + outer.scrollTop = s1 + inner.scrollTop = s2 + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 4 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 4 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom') + assertSame(`floating-2scroll ${s1}/${s2}`, own, fui) + }) + } +}) + +describe('real-DOM parity — fixed × scaled containing block sweep', () => { + for (const placement of ['bottom', 'top-start', 'right', 'left-end'] as Placement[]) { + it(placement, async () => { + const wrapper = box({ + position: 'relative', + transform: 'scale(1.25)', + transformOrigin: 'top left', + left: '180px', + top: '160px', + width: '420px', + height: '320px', + border: '1px solid' + }) + const anchor = box( + { position: 'absolute', left: '120px', top: '120px', width: '90px', height: '34px' }, + wrapper + ) + const floating = box({ position: 'fixed', width: '200px', height: '120px' }, wrapper) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement, 'fixed') + assertSame(`fixed-scaled ${placement}`, own, fui) + }) + } +}) + +describe('real-DOM parity — scaled clipping ancestor (getInnerBoundingClientRect scale)', () => { + for (const placement of ['bottom', 'right'] as Placement[]) { + it(placement, async () => { + const scroller = box({ + position: 'relative', + transform: 'scale(1.25)', + transformOrigin: 'top left', + overflow: 'auto', + left: '120px', + top: '120px', + width: '300px', + height: '220px', + border: '1px solid' + }) + const tall = box({ height: '700px', width: '600px', position: 'relative' }, scroller) + const anchor = box( + { position: 'absolute', left: '60px', top: '150px', width: '90px', height: '34px' }, + tall + ) + // floating inside the scaled scroller → the scaled clip rect bounds it. + const floating = box({ position: 'absolute', width: '200px', height: '160px' }, tall) + const mw: Built = { + own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 4 })], + fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 4 })] + } + const { own, fui } = await bothPositions(anchor, floating, mw, placement) + assertSame(`scaled-clip ${placement}`, own, fui) + }) + } +}) + describe('real-DOM parity — arrow + size in real layout', () => { it('arrow centering (bottom) matches', async () => { const anchor = box({ diff --git a/src/uix/soma/layers/floating/engine/engine.test.ts b/src/uix/soma/layers/floating/engine/engine.test.ts index b0f8b1a65..d8b4c2a46 100644 --- a/src/uix/soma/layers/floating/engine/engine.test.ts +++ b/src/uix/soma/layers/floating/engine/engine.test.ts @@ -829,3 +829,638 @@ describe('parity — coordinate-space variants (scroll · scale · rtl)', () => } } }) + +// ── offset — every form (number, negative, crossAxis, alignmentAxis) ────────── + +describe('parity — offset forms', () => { + const base: Scenario = { + reference: { x: 400, y: 300, width: 90, height: 32 }, + floatingDims: { width: 200, height: 120 }, + clip: VIEWPORT + } + const FORMS: { name: string; opt: number | { mainAxis?: number; crossAxis?: number; alignmentAxis?: number | null } }[] = [ + { name: 'number 12', opt: 12 }, + { name: 'number 0', opt: 0 }, + { name: 'negative -10', opt: -10 }, + { name: 'mainAxis only', opt: { mainAxis: 14 } }, + { name: 'crossAxis only', opt: { crossAxis: 18 } }, + { name: 'main+cross', opt: { mainAxis: 8, crossAxis: -12 } }, + { name: 'alignmentAxis', opt: { mainAxis: 8, alignmentAxis: 16 } }, + { name: 'negative alignmentAxis', opt: { mainAxis: 8, alignmentAxis: -16 } } + ] + for (const placement of ['top', 'bottom-start', 'right-end', 'left'] as Placement[]) { + for (const form of FORMS) { + it(`${placement} · ${form.name}`, async () => { + const { own, fui } = await compare(base, placement, (kind) => + [kind === 'own' ? ownOffset(form.opt) : fuiOffset(form.opt)].filter(Boolean) + ) + assertParity(`offset ${placement}/${form.name}`, own, fui) + }) + } + } +}) + +// ── shift — crossAxis-only, both axes, limitShift with offsets ──────────────── + +describe('parity — shift axis + limiter variants', () => { + const scenario: Scenario = { + reference: { x: 900, y: 720, width: 90, height: 32 }, + floatingDims: { width: 200, height: 140 }, + clip: VIEWPORT + } + const VARIANTS = [ + { name: 'cross-only', main: false, cross: true, limiter: undefined as undefined | (() => unknown) }, + { name: 'both-axes', main: true, cross: true, limiter: undefined }, + { name: 'limit main', main: true, cross: false, limit: true }, + { name: 'limit both', main: true, cross: true, limit: true }, + { name: 'limit offset', main: true, cross: true, limit: true, limitOffset: 10 } + ] + for (const placement of ['bottom', 'right'] as Placement[]) { + for (const v of VARIANTS as { name: string; main: boolean; cross: boolean; limit?: boolean; limitOffset?: number }[]) { + it(`${placement} · ${v.name}`, async () => { + const { own, fui } = await compare(scenario, placement, (kind) => { + const o = kind === 'own' + const limiter = v.limit + ? o + ? ownLimitShift({ offset: v.limitOffset }) + : fuiLimitShift({ offset: v.limitOffset }) + : undefined + const opts = { mainAxis: v.main, crossAxis: v.cross, limiter, padding: 4 } + return [ + o ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + o ? ownShift(opts as any) : fuiShift(opts as any) + ].filter(Boolean) + }) + assertParity(`shift ${placement}/${v.name}`, own, fui) + }) + } + } +}) + +// ── flip — fallbackPlacements, fallbackAxisSideDirection, crossAxis, strategy ── + +describe('parity — flip option variants', () => { + const scenario: Scenario = { + reference: { x: 410, y: 730, width: 90, height: 32 }, + floatingDims: { width: 220, height: 160 }, + clip: VIEWPORT + } + const VARIANTS: { name: string; opt: Record }[] = [ + { name: 'explicit fallbackPlacements', opt: { fallbackPlacements: ['right', 'left', 'top'] } }, + { name: 'fallbackAxisSideDirection start', opt: { fallbackAxisSideDirection: 'start' } }, + { name: 'fallbackAxisSideDirection end', opt: { fallbackAxisSideDirection: 'end' } }, + { name: 'crossAxis false', opt: { crossAxis: false } }, + { name: 'crossAxis alignment', opt: { crossAxis: 'alignment' } }, + { name: 'mainAxis false', opt: { mainAxis: false } }, + { name: 'fallbackStrategy initialPlacement', opt: { fallbackStrategy: 'initialPlacement' } }, + { name: 'flipAlignment false', opt: { flipAlignment: false } } + ] + for (const placement of ['bottom', 'bottom-start', 'right'] as Placement[]) { + for (const v of VARIANTS) { + it(`${placement} · ${v.name}`, async () => { + const { own, fui } = await compare(scenario, placement, (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + kind === 'own' ? ownFlip(v.opt as any) : fuiFlip(v.opt as any) + ].filter(Boolean) + ) + assertParity(`flip ${placement}/${v.name}`, own, fui) + }) + } + } +}) + +// ── flip cross-axis — getAlignmentSides when the reference outsizes floating ─── + +describe('parity — flip cross-axis with reference wider/taller than floating', () => { + for (const placement of ['bottom-start', 'bottom-end', 'right-start', 'left-end'] as Placement[]) { + it(placement, async () => { + const scenario: Scenario = { + // reference larger than floating on the alignment axis flips + // getAlignmentSides' mainAlignmentSide. + reference: { x: 60, y: 60, width: 320, height: 240 }, + floatingDims: { width: 120, height: 90 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, placement, (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' ? ownFlip({ crossAxis: true }) : fuiFlip({ crossAxis: true }) + ].filter(Boolean) + ) + assertParity(`flip-cross ${placement}`, own, fui) + }) + } +}) + +// ── hide — escaped strategy ─────────────────────────────────────────────────── + +describe('parity — hide escaped strategy', () => { + const CASES = [ + { name: 'inside', ref: { x: 410, y: 350 }, fx: 410, fy: 350 }, + { name: 'floating-escaped', ref: { x: 410, y: 350 }, fx: 410, fy: 1300 } + ] + for (const c of CASES) { + it(c.name, async () => { + const scenario: Scenario = { + reference: { x: c.ref.x, y: c.ref.y, width: 90, height: 32 }, + floatingDims: { width: 180, height: 100 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' + ? ownHide({ strategy: 'escaped', padding: 0 }) + : fuiHide({ strategy: 'escaped', padding: 0 }) + ].filter(Boolean) + ) + const oe = (own.middlewareData.hide ?? {}) as { escaped?: boolean } + const fe = (fui.middlewareData.hide ?? {}) as { escaped?: boolean } + expect(!!oe.escaped, `${c.name} escaped`).toBe(!!fe.escaped) + }) + } +}) + +// ── size — aligned placements + rtl widthSide branch ────────────────────────── + +describe('parity — size on aligned placements (+rtl)', () => { + for (const placement of [ + 'bottom-start', + 'bottom-end', + 'top-start', + 'right-start', + 'right-end', + 'left-end' + ] as Placement[]) { + for (const rtl of [false, true]) { + it(`${placement} rtl=${rtl}`, async () => { + const scenario: Scenario = { + reference: { x: 300, y: 300, width: 90, height: 32 }, + floatingDims: { width: 360, height: 320 }, + clip: VIEWPORT, + rtl + } + let ownAW = 0, + ownAH = 0, + fuiAW = 0, + fuiAH = 0 + await compare(scenario, placement, (kind) => { + const apply = (a: { availableWidth: number; availableHeight: number }) => { + if (kind === 'own') { + ownAW = a.availableWidth + ownAH = a.availableHeight + } else { + fuiAW = a.availableWidth + fuiAH = a.availableHeight + } + } + return [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' ? ownSize({ padding: 0, apply }) : fuiSize({ padding: 0, apply }) + ].filter(Boolean) + }) + expect(ownAW, `${placement} rtl=${rtl} AW`).toBeCloseTo(fuiAW, 4) + expect(ownAH, `${placement} rtl=${rtl} AH`).toBeCloseTo(fuiAH, 4) + }) + } + } +}) + +// ── arrow — alignmentOffset reset interacting with offset + flip ────────────── + +describe('parity — arrow alignmentOffset reset + offset/flip short-circuit', () => { + for (const placement of ['bottom-start', 'bottom-end', 'right-start', 'left-start'] as Placement[]) { + it(placement, async () => { + // Tiny reference + large arrow padding triggers arrow's shouldAddOffset + // (alignmentOffset + reset). offset must then short-circuit and flip skip. + const scenario: Scenario = { + reference: { x: 420, y: 360, width: 14, height: 14 }, + floatingDims: { width: 260, height: 170 }, + clip: VIEWPORT, + arrowDims: { width: 20, height: 20 } + } + const { own, fui } = await compare(scenario, placement, (kind, ctx) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' ? ownFlip({ padding: 0 }) : fuiFlip({ padding: 0 }), + kind === 'own' + ? ownArrow({ element: ctx.arrowEl as Element, padding: 20 }) + : fuiArrow({ element: ctx.arrowEl as never, padding: 20 }) + ].filter(Boolean) + ) + assertParity(`arrow-reset ${placement}`, own, fui, { arrow: true }) + }) + } +}) + +// ── collisionPadding object (per-side) ──────────────────────────────────────── + +describe('parity — per-side collisionPadding', () => { + const scenario: Scenario = { + reference: { x: 410, y: 720, width: 90, height: 32 }, + floatingDims: { width: 220, height: 150 }, + clip: VIEWPORT + } + for (const placement of ['bottom', 'top', 'right'] as Placement[]) { + it(placement, async () => { + const pad = { top: 20, right: 8, bottom: 30, left: 12 } + const { own, fui } = await compare(scenario, placement, (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownShift({ mainAxis: true, crossAxis: true, padding: pad }) + : fuiShift({ mainAxis: true, crossAxis: true, padding: pad }), + kind === 'own' ? ownFlip({ padding: pad }) : fuiFlip({ padding: pad }) + ].filter(Boolean) + ) + assertParity(`padding-obj ${placement}`, own, fui) + }) + } +}) + +// ════════ Coverage-gap sweep (from the parity-coverage-gaps audit) ════════════ + +// flip — terminal bestFit when nothing fits + fallbackStrategy divergence. +describe('parity — flip bestFit / fallbackStrategy (nothing fits)', () => { + const scenario: Scenario = { + reference: { x: 120, y: 140, width: 60, height: 24 }, + floatingDims: { width: 280, height: 280 }, + clip: { x: 0, y: 0, width: 300, height: 300 } + } + for (const strat of ['bestFit', 'initialPlacement'] as const) { + it(`fallbackStrategy ${strat}`, async () => { + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownFlip({ fallbackStrategy: strat, padding: 0 }) + : fuiFlip({ fallbackStrategy: strat, padding: 0 }) + ].filter(Boolean) + ) + assertParity(`bestFit ${strat}`, own, fui) + }) + } +}) + +// flip crossAxis 'alignment' guard — must NOT leave the axis when main fits. +describe('parity — flip crossAxis alignment vs true', () => { + const scenario: Scenario = { + reference: { x: 410, y: 8, width: 90, height: 32 }, + floatingDims: { width: 200, height: 140 }, + clip: VIEWPORT + } + for (const cross of ['alignment', true] as const) { + it(`crossAxis ${cross}`, async () => { + const { own, fui } = await compare(scenario, 'bottom-start', (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownFlip({ crossAxis: cross, padding: 0 }) + : fuiFlip({ crossAxis: cross, padding: 0 }) + ].filter(Boolean) + ) + assertParity(`flip-cross-${cross}`, own, fui) + }) + } +}) + +// flip — multi-step oscillation (≥3 placements tried; overflowsData accumulates). +describe('parity — flip multi-step', () => { + for (const placement of ['bottom-start', 'top-end', 'right-start'] as Placement[]) { + it(placement, async () => { + const scenario: Scenario = { + reference: { x: 30, y: 720, width: 90, height: 60 }, + floatingDims: { width: 240, height: 200 }, + clip: { x: 0, y: 0, width: 360, height: 800 } + } + const { own, fui } = await compare(scenario, placement, (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' ? ownFlip({ padding: 0 }) : fuiFlip({ padding: 0 }) + ].filter(Boolean) + ) + assertParity(`flip-multi ${placement}`, own, fui) + }) + } +}) + +// size — single-axis shift (the exact soma avoidCollisions config) + noShift shrink. +describe('parity — size single-axis shift + symmetric shrink', () => { + const CASES = [ + { name: 'mainAxis-shift', placement: 'bottom' as Placement, ref: { x: 410, y: 600 }, dims: { width: 200, height: 360 }, shift: true }, + { name: 'no-shift-shrink', placement: 'bottom' as Placement, ref: { x: 60, y: 600 }, dims: { width: 380, height: 360 }, shift: false } + ] + for (const c of CASES) { + it(c.name, async () => { + const scenario: Scenario = { + reference: { x: c.ref.x, y: c.ref.y, width: 90, height: 32 }, + floatingDims: c.dims, + clip: VIEWPORT + } + let oAW = 0, + oAH = 0, + fAW = 0, + fAH = 0 + await compare(scenario, c.placement, (kind) => { + const apply = (a: { availableWidth: number; availableHeight: number }) => { + if (kind === 'own') { + oAW = a.availableWidth + oAH = a.availableHeight + } else { + fAW = a.availableWidth + fAH = a.availableHeight + } + } + return [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + c.shift + ? kind === 'own' + ? ownShift({ mainAxis: true, crossAxis: false, padding: 0 }) + : fuiShift({ mainAxis: true, crossAxis: false, padding: 0 }) + : undefined, + kind === 'own' ? ownSize({ padding: 0, apply }) : fuiSize({ padding: 0, apply }) + ].filter(Boolean) + }) + expect(oAW, `${c.name} AW`).toBeCloseTo(fAW, 4) + expect(oAH, `${c.name} AH`).toBeCloseTo(fAH, 4) + }) + } +}) + +// limitShift — offset object, axis toggles, origin-side cross-axis clamp. +describe('parity — limitShift option variants', () => { + const scenario: Scenario = { + reference: { x: 60, y: 60, width: 90, height: 32 }, + floatingDims: { width: 220, height: 150 }, + clip: VIEWPORT + } + const VARIANTS = [ + { name: 'offset-object', opt: { offset: { mainAxis: 12, crossAxis: 6 } } }, + { name: 'mainAxis-false', opt: { mainAxis: false } }, + { name: 'crossAxis-false', opt: { crossAxis: false } } + ] + for (const placement of ['top-start', 'left-start'] as Placement[]) { + for (const v of VARIANTS) { + it(`${placement} · ${v.name}`, async () => { + const { own, fui } = await compare(scenario, placement, (kind) => { + const o = kind === 'own' + return [ + o ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + o + ? ownShift({ mainAxis: true, crossAxis: true, limiter: ownLimitShift(v.opt), padding: 0 }) + : fuiShift({ mainAxis: true, crossAxis: true, limiter: fuiLimitShift(v.opt), padding: 0 }) + ].filter(Boolean) + }) + assertParity(`limit ${placement}/${v.name}`, own, fui) + }) + } + } +}) + +// offset — crossAxis number on a vertical placement under RTL (the mirror). +describe('parity — offset crossAxis under RTL', () => { + for (const placement of ['bottom', 'top', 'bottom-start'] as Placement[]) { + it(placement, async () => { + const scenario: Scenario = { + reference: { x: 400, y: 300, width: 90, height: 32 }, + floatingDims: { width: 200, height: 120 }, + clip: VIEWPORT, + rtl: true + } + const { own, fui } = await compare(scenario, placement, (kind) => + [ + kind === 'own' + ? ownOffset({ mainAxis: 8, crossAxis: 24 }) + : fuiOffset({ mainAxis: 8, crossAxis: 24 }) + ].filter(Boolean) + ) + assertParity(`offset-rtl-cross ${placement}`, own, fui) + }) + } +}) + +// offset — re-runs after flip changes placement (the alignmentOffset short-circuit +// must NOT fire when the placement differs). +it('parity — offset re-runs after flip changes placement (arrow alignmentOffset)', async () => { + const scenario: Scenario = { + reference: { x: 420, y: 740, width: 14, height: 14 }, + floatingDims: { width: 260, height: 170 }, + clip: VIEWPORT, + arrowDims: { width: 20, height: 20 } + } + const { own, fui } = await compare(scenario, 'bottom-start', (kind, ctx) => + [ + kind === 'own' + ? ownOffset({ mainAxis: 8, alignmentAxis: 4 }) + : fuiOffset({ mainAxis: 8, alignmentAxis: 4 }), + kind === 'own' ? ownFlip({ padding: 0 }) : fuiFlip({ padding: 0 }), + kind === 'own' + ? ownArrow({ element: ctx.arrowEl as Element, padding: 18 }) + : fuiArrow({ element: ctx.arrowEl as never, padding: 18 }) + ].filter(Boolean) + ) + assertParity('offset-after-flip', own, fui, { arrow: true }) +}) + +// hide — exact-boundary referenceHidden + numeric referenceHiddenOffsets. +describe('parity — hide boundary + offsets', () => { + const CASES = [ + { name: 'just-out-right', ref: { x: 1000, y: 100, width: 80, height: 30 } }, + { name: 'asym-out', ref: { x: -30, y: 790, width: 80, height: 30 } } + ] + for (const c of CASES) { + it(c.name, async () => { + const scenario: Scenario = { + reference: c.ref, + floatingDims: { width: 180, height: 100 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' + ? ownHide({ strategy: 'referenceHidden', padding: 0 }) + : fuiHide({ strategy: 'referenceHidden', padding: 0 }) + ].filter(Boolean) + ) + const oh = (own.middlewareData.hide ?? {}) as { + referenceHidden?: boolean + referenceHiddenOffsets?: Record + } + const fh = (fui.middlewareData.hide ?? {}) as { + referenceHidden?: boolean + referenceHiddenOffsets?: Record + } + expect(!!oh.referenceHidden, `${c.name} hidden`).toBe(!!fh.referenceHidden) + for (const side of ['top', 'right', 'bottom', 'left'] as const) { + expect(oh.referenceHiddenOffsets?.[side], `${c.name} off.${side}`).toBeCloseTo( + fh.referenceHiddenOffsets?.[side] as number, + 4 + ) + } + }) + } +}) + +// shift — the data.x/data.y delta payload (consumers read it). +it('parity — shift data delta', async () => { + const scenario: Scenario = { + reference: { x: 900, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownShift({ mainAxis: true, crossAxis: true, padding: 0 }) + : fuiShift({ mainAxis: true, crossAxis: true, padding: 0 }) + ].filter(Boolean) + ) + const od = own.middlewareData.shift as { x?: number; y?: number } + const fd = fui.middlewareData.shift as { x?: number; y?: number } + expect(od.x, 'shift.x').toBeCloseTo(fd.x as number, 4) + expect(od.y, 'shift.y').toBeCloseTo(fd.y as number, 4) +}) + +// arrow — largestPossiblePadding clamp engaging (small floating, big padding). +it('parity — arrow largestPossiblePadding clamp', async () => { + const scenario: Scenario = { + reference: { x: 410, y: 350, width: 80, height: 30 }, + floatingDims: { width: 40, height: 40 }, + clip: VIEWPORT, + arrowDims: { width: 12, height: 12 } + } + const { own, fui } = await compare(scenario, 'bottom', (kind, ctx) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownArrow({ element: ctx.arrowEl as Element, padding: 30 }) + : fuiArrow({ element: ctx.arrowEl as never, padding: 30 }) + ].filter(Boolean) + ) + assertParity('arrow-clamp', own, fui, { arrow: true }) +}) + +// reset loop — bare-true reset preserves mutated coords (custom middleware). +it('parity — bare-true reset preserves running coords', async () => { + const scenario: Scenario = { + reference: { x: 200, y: 200, width: 80, height: 30 }, + floatingDims: { width: 160, height: 90 }, + clip: VIEWPORT + } + const ownProbe = { seen: NaN } + const ownMw: Middleware[] = [ + { + name: 'bump', + options: {}, + fn(s) { + const d = s.middlewareData.bump as { done?: boolean } | undefined + if (d?.done) return {} + return { x: s.x + 37, data: { done: true }, reset: true } + } + }, + { + name: 'probe', + options: {}, + fn(s) { + ownProbe.seen = s.x + return {} + } + } + ] + const own = runMiddleware('bottom', 'absolute', ownMw, snapshotOf(scenario)) + let fuiSeen = NaN + const fui = await fuiComputePosition({} as never, {} as never, { + placement: 'bottom', + strategy: 'absolute', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: platformOf(scenario, {}, {}) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + middleware: [ + { + name: 'bump', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fn(s: any) { + if (s.middlewareData.bump?.done) return {} + return { x: s.x + 37, data: { done: true }, reset: true } + } + }, + { + name: 'probe', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fn(s: any) { + fuiSeen = s.x + return {} + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any + }) + expect(ownProbe.seen, 'probe sees mutated x').toBeCloseTo(fuiSeen, 4) + expect(own.x, 'final x').toBeCloseTo(fui.x, 4) +}) + +// reset loop — MAX_RESET_COUNT bail-out lands on the same placement. +it('parity — MAX_RESET_COUNT cap settles identically', async () => { + const scenario: Scenario = { + reference: { x: 200, y: 200, width: 80, height: 30 }, + floatingDims: { width: 160, height: 90 }, + clip: VIEWPORT + } + const own = runMiddleware('bottom', 'absolute', [ + { + name: 'osc', + options: {}, + fn(s) { + return { reset: { placement: s.placement === 'bottom' ? 'top' : 'bottom' } } + } + } + ], snapshotOf(scenario)) + const fui = await fuiComputePosition({} as never, {} as never, { + placement: 'bottom', + strategy: 'absolute', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: platformOf(scenario, {}, {}) as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + middleware: [ + { + name: 'osc', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fn(s: any) { + return { reset: { placement: s.placement === 'bottom' ? 'top' : 'bottom' } } + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any + }) + expect(own.placement, 'cap placement').toBe(fui.placement) + expect(own.x, 'cap x').toBeCloseTo(fui.x, 4) + expect(own.y, 'cap y').toBeCloseTo(fui.y, 4) +}) + +// per-side padding object combined with non-unit offsetScale. +describe('parity — padding object × offsetScale', () => { + for (const placement of ['bottom', 'right'] as Placement[]) { + it(placement, async () => { + const scenario: Scenario = { + reference: { x: 410, y: 700, width: 90, height: 32 }, + floatingDims: { width: 220, height: 150 }, + clip: VIEWPORT, + offsetScale: { x: 1.25, y: 0.9 } + } + const pad = { top: 16, right: 6, bottom: 24, left: 10 } + const { own, fui } = await compare(scenario, placement, (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownShift({ mainAxis: true, crossAxis: true, padding: pad }) + : fuiShift({ mainAxis: true, crossAxis: true, padding: pad }), + kind === 'own' ? ownFlip({ padding: pad }) : fuiFlip({ padding: pad }) + ].filter(Boolean) + ) + assertParity(`pad-scale ${placement}`, own, fui) + }) + } +}) diff --git a/src/uix/soma/layers/floating/engine/rects.ts b/src/uix/soma/layers/floating/engine/rects.ts index f95b8b8ab..f25d10b71 100644 --- a/src/uix/soma/layers/floating/engine/rects.ts +++ b/src/uix/soma/layers/floating/engine/rects.ts @@ -94,7 +94,7 @@ function getNodeScroll(el: Element | Window): Coords { /** Horizontal offset of the document scrollbar (non-zero only when the scrollbar * sits on the left, e.g. some RTL systems). Mirrors floating-ui. */ -function getWindowScrollBarX(element: Element, win: Window, rect?: Rect): number { +export function getWindowScrollBarX(element: Element, win: Window, rect?: Rect): number { const leftScroll = getNodeScroll(element).x if (!rect) return getBoundingClientRect(getDocumentElement(element), win).x + leftScroll return rect.x + leftScroll @@ -209,7 +209,12 @@ function getRectRelativeToOffsetParent( const documentElement = getDocumentElement(offsetParent) const isFixed = strategy === 'fixed' - const rect = getBoundingClientRect(el, win, true, isFixed ? offsetParent : undefined) + // Scale basis is ALWAYS the offsetParent (matches floating-ui), not the + // element's own scale. They differ when the offsetParent is anisotropically + // scaled or carries a border (getScale's fallback rounds differently for the + // two boxes) — using the element's scale shifted the reference rect by a + // sub-pixel on such parents. + const rect = getBoundingClientRect(el, win, true, offsetParent) let scroll: Coords = createCoords(0) const offsets: Coords = createCoords(0) @@ -221,8 +226,16 @@ function getRectRelativeToOffsetParent( const offsetRect = getBoundingClientRect(offsetParent, win, true) offsets.x = offsetRect.x + offsetParent.clientLeft offsets.y = offsetRect.y + offsetParent.clientTop + } else if (documentElement) { + // Window offsetParent: account for a left-side document scrollbar (RTL + // systems, or a vertical writing-mode that flips the inline origin). + // ~0 on a normal LTR document, so this was silently fine until exercised. + offsets.x = getWindowScrollBarX(documentElement, win) } } + if (isFixed && !isOffsetParentAnElement && documentElement) { + offsets.x = getWindowScrollBarX(documentElement, win) + } const htmlOffset = documentElement && !isOffsetParentAnElement && !isFixed