test(floating): dual-system parity suites — find + fix 9 rects/clipping bugs

Adds two suites asserting the in-house engine == @floating-ui (flag stays OFF;
no removal, no migration). Together they exercise the whole pipeline and caught
9 real bugs in the P1 DOM-read layer; all fixed and re-verified.

Suites:
- engine.test.ts — 232 synthetic math cases: runMiddleware vs @floating-ui/core
  over identical synthetic rects. Full 12-placement × 7-edge matrix (full chain),
  flip-at-edges, shift (main/cross × limiter), arrow (+ alignmentOffset), size,
  hide, extremes (oversized/zero-size/fractional/negative-padding/huge-offset),
  and scroll/scale/rtl variants. Pixel-identical.
- engine-dom.svelte.test.ts — 60 real-DOM cases (chromium/client project):
  BOTH systems' computePosition on the SAME real elements — the only suite that
  exercises rects.ts + clipping.ts. Nested scroll, transformed + CSS-scaled
  offsetParent, fixed strategy, scrolled page, individual-transform containing
  block, static-table-cell offsetParent, position:fixed escaping a scroll
  container, arrow + size.

Bugs fixed (independently confirmed by the floating-engine-parity-audit workflow,
25 agents):
- rects.ts getRectRelativeToOffsetParent: inverted scroll/offset signs (+ missing
  htmlOffset) — broke every scrolled page / positioned offsetParent.
- rects.ts convert: early-returned for a window offsetParent (=== win instead of
  === documentElement) leaving viewportDelta = 0, plus inverted signs, plus the
  offsetParent rect must be RAW (includeScale:false) and scale must be applied.
- overflow.ts: scale the element rect by offsetScale (rect·scale + viewportDelta)
  so CSS-scaled offsetParents detect overflow correctly; synthetic platform.convert
  updated to model rect·scale too.
- rects.ts isContainingBlock: add translate/scale/rotate, gate filter/backdrop
  behind !isWebKit(), drop container-type (matches shipped floating-ui), widen
  the willChange regex. New isWebKit()/isTableElement() helpers.
- rects.ts getOffsetParent: skip static td/th, not just table.
- clipping.ts: getClippingElementAncestors (+ hasFixedPositionAncestor) — drop the
  body and any overflow ancestor the positioned element escapes via a fixed/absolute
  containing block; getViewportRect gates the visual-viewport offset on isWebKit();
  getOverflowAncestors includes win.visualViewport for auto-update zoom tracking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent ef4447e78e
commit a2500e9e31

@ -137,19 +137,57 @@ 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 this pass
## Verification done — comparison suites + 9 bugs found & fixed
- **Math parity guard — `engine/engine.test.ts` (13 cases, all green).** `runMiddleware`
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.
- **Math/middleware — `engine/engine.test.ts` (232 cases, green).** `runMiddleware`
(the real loop) vs `@floating-ui/core`'s `computePosition`, both fed identical
synthetic rects (a synthetic floating-ui `platform` mirrors `snapshotOf`). Covers
offset (+alignmentAxis), flip (overflow→flip AND fits→no-flip), shift (+limitShift),
arrow (centerOffset + alignmentOffset path), size (available W/H), hide (true/false),
the full chain with `viewportDelta`/scroll, and RTL. **Pixel-identical.** This is the
authoritative proof the engine math is correct — keep it as the regression guard.
- **Browser end-to-end (flag flipped to true) — engine runs + positions correctly.**
Popover demo, 4 sides: own engine executed (instrumented count) and centered exactly
on the trigger. `npm run check` at baseline (61, 0 new); soma overlay providers
(popover/dropdown/tooltip/combobox/link-preview/context-menu) all green with flag off.
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.
**Bugs found & fixed (all confirmed independently by the adversarial audit workflow
`floating-engine-parity-audit`, 25 agents):**
1. **`rects.ts` sign inversion (HIGH)** — `getRectRelativeToOffsetParent` used
`rect − scroll + offsets`; spec is `rect + scroll − offsets − htmlOffset`. Broke any
scrolled page / positioned offsetParent. The synthetic suite missed it (injects rects).
2. **`rects.ts` convert early-return + signs (HIGH)** — gated on `offsetParent === win`
(returned rect unconverted for a window offsetParent → `viewportDelta = 0`); spec
gates on `=== documentElement` and folds in page scroll with the mirror signs.
3. **scale not applied (overflow + convert) (MED)** — `viewportDelta` linearisation
couldn't carry `rect·scale`; CSS-scaled offsetParents mis-detected overflow. Fixed:
`convert` applies `scale`, `overflow.ts` does `rect·offsetScale + viewportDelta`,
and the synthetic `platformOf.convert` now models `rect·scale` too.
4. **convert offsetRect scale basis (MED)** — used `includeScale:true`; spec uses RAW
(`false`) in convert (vs `true` in getRect — an intentional asymmetry).
5. **`isContainingBlock` (MED)** — added individual transforms `translate`/`scale`/
`rotate`, gated `filter`/`backdrop-filter` behind `!isWebKit()`, dropped
`container-type` (the shipped floating-ui omits it), widened the willChange regex.
6. **`getOffsetParent` table skip (MED)** — skipped only `table`; spec skips `td`/`th`
too (`isTableElement`).
7. **`clipping` containing-block filtering (MED)** — added `getClippingElementAncestors`
(body drop + fixed/absolute-escape drop) + `hasFixedPositionAncestor`; a fixed overlay
nested in a scroll container is no longer wrongly clipped to it.
8. **`getViewportRect` visual-viewport gating (MED)** — gate on `isWebKit()`, not
`strategy === 'fixed'` (non-WebKit applies the offset for both strategies under zoom).
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.
### ⚠️ Preview-tool caveat for the broad browser A/B (read before P2/P3)

@ -7,7 +7,14 @@
import { getDocumentElement, getNodeName, getParentNode, isElement, isHTMLElement } from '$adom'
import type { Strategy } from '../placement'
import type { Rect } from './types'
import { createCoords, getBoundingClientRect, getScale, rectToClientRect } from './rects'
import {
createCoords,
getBoundingClientRect,
getScale,
isContainingBlock,
isWebKit,
rectToClientRect
} from './rects'
const { max, min } = Math
@ -38,21 +45,80 @@ function getNearestOverflowAncestor(node: Node, win: Window): HTMLElement {
return getNearestOverflowAncestor(parent, win)
}
/** The element's overflow/clip ancestors + the window (single document).
* Exported for `auto-update.ts` (it attaches scroll/resize listeners to each). */
/** The element's overflow/clip ancestors + the window + the visualViewport
* (single document). Exported for `auto-update.ts`, which attaches scroll/resize
* listeners to each (the visualViewport entry is what makes overlays reposition
* on pinch-zoom). `getClippingRect` filters this to `Element`s, so the
* visualViewport never reaches the clip math. */
export function getOverflowAncestors(
node: Node,
win: Window,
list: (Element | Window)[] = []
): (Element | Window)[] {
list: (Element | Window | VisualViewport)[] = []
): (Element | Window | VisualViewport)[] {
const scrollable = getNearestOverflowAncestor(node, win)
const isBody = scrollable === scrollable.ownerDocument?.body
if (isBody) {
return list.concat(win, isOverflowElement(scrollable, win) ? scrollable : [])
return list.concat(
win,
win.visualViewport ?? [],
isOverflowElement(scrollable, win) ? scrollable : []
)
}
return list.concat(scrollable, getOverflowAncestors(scrollable, win, []))
}
/** Walk `element`'s parents to find the nearest fixed-positioned ancestor before
* `stopNode` — used to decide whether a scroll container's clip actually applies
* to a positioned element. Mirrors floating-ui. */
function hasFixedPositionAncestor(element: Node, stopNode: Node, win: Window): boolean {
const parent = getParentNode(element)
if (parent === stopNode || !isElement(parent) || isLastTraversable(parent)) return false
return getComputed(parent, win).position === 'fixed' || hasFixedPositionAncestor(parent, stopNode, win)
}
/**
* The overflow ancestors that actually CLIP the positioned element — the spec's
* `getClippingElementAncestors`. Two drops vs the raw overflow walk: the `<body>`
* is never a clipping ancestor, and an overflow ancestor is dropped when a
* fixed/absolute containing block sits between it and the element (so the element
* escapes that container's clip). Without this, a `position: fixed` overlay
* nested inside a scroll container is wrongly clipped to the container.
* (No per-call cache — floating-ui's is a perf optimisation, not correctness.)
*/
function getClippingElementAncestors(element: Element, win: Window): Element[] {
let result = getOverflowAncestors(element, win).filter(
(el): el is Element => isElement(el) && getNodeName(el) !== 'body'
)
let currentContainingBlockComputedStyle: CSSStyleDeclaration | null = null
const elementIsFixed = getComputed(element, win).position === 'fixed'
let currentNode: Node | null = elementIsFixed ? getParentNode(element) : element
while (isElement(currentNode) && !isLastTraversable(currentNode)) {
const computedStyle = getComputed(currentNode, win)
const currentNodeIsContaining = isContainingBlock(currentNode, win)
if (!currentNodeIsContaining && computedStyle.position === 'fixed') {
currentContainingBlockComputedStyle = null
}
const shouldDropCurrentNode = elementIsFixed
? !currentNodeIsContaining && !currentContainingBlockComputedStyle
: (!currentNodeIsContaining &&
computedStyle.position === 'static' &&
!!currentContainingBlockComputedStyle &&
(currentContainingBlockComputedStyle.position === 'absolute' ||
currentContainingBlockComputedStyle.position === 'fixed')) ||
(isOverflowElement(currentNode, win) &&
!currentNodeIsContaining &&
hasFixedPositionAncestor(element, currentNode, win))
if (shouldDropCurrentNode) {
result = result.filter((ancestor) => ancestor !== currentNode)
} else {
currentContainingBlockComputedStyle = computedStyle
}
currentNode = getParentNode(currentNode)
}
return result
}
function getViewportRect(el: Element, strategy: Strategy, win: Window): Rect {
const html = getDocumentElement(el)
const vv = win.visualViewport
@ -63,7 +129,12 @@ function getViewportRect(el: Element, strategy: Strategy, win: Window): Rect {
if (vv) {
width = vv.width
height = vv.height
if (strategy === 'fixed') {
// floating-ui gates the visual-viewport offset on engine, not strategy:
// non-WebKit applies it for BOTH strategies (so a pinch-zoomed Chrome/
// Firefox absolute overlay clips against the shifted viewport), WebKit only
// for `fixed`.
const visualViewportBased = isWebKit(win)
if (!visualViewportBased || strategy === 'fixed') {
x = vv.offsetLeft
y = vv.offsetTop
}
@ -107,9 +178,7 @@ export function getClippingRect(
win: Window
): Rect {
const ancestors: (Element | 'viewport')[] =
boundary === 'clippingAncestors'
? getOverflowAncestors(element, win).filter((a): a is Element => isElement(a))
: [...boundary]
boundary === 'clippingAncestors' ? getClippingElementAncestors(element, win) : [...boundary]
ancestors.push('viewport')
const first = clipOf(element, ancestors[0], strategy, win)

@ -0,0 +1,443 @@
// Real-DOM parity: runs BOTH positioning systems — our own `computePosition`
// (engine/) and `@floating-ui/dom`'s — against the SAME real elements in a real
// browser (chromium, via the `client` vitest project) and asserts identical
// coords / placement. Unlike engine.test.ts (synthetic rects), this exercises the
// DOM-read layer — rects.ts (offsetParent + scale-aware rects + viewport
// conversion) and clipping.ts (the overflow-ancestor walk) — in the limit
// scenarios that layer exists for: nested scroll containers, transformed
// ancestors, the fixed strategy, a scrolled page, and positioned offsetParents.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createActiveDom } from '$adom'
import { computePosition as ownComputePosition } from './compute'
import {
arrow as ownArrow,
flip as ownFlip,
offset as ownOffset,
shift as ownShift,
size as ownSize
} from './middleware'
import {
arrow as fuiArrow,
computePosition as fuiComputePosition,
flip as fuiFlip,
offset as fuiOffset,
shift as fuiShift,
size as fuiSize,
type Middleware as FuiMiddleware
} from '@floating-ui/dom'
import type { Middleware } from './types'
import type { Placement, Strategy } from '../placement'
const dom = createActiveDom()
const trash: HTMLElement[] = []
function box(styles: Partial<CSSStyleDeclaration>, parent: HTMLElement = document.body): HTMLDivElement {
const d = document.createElement('div')
Object.assign(d.style, styles)
parent.appendChild(d)
if (parent === document.body) trash.push(d)
return d
}
beforeEach(() => {
window.scrollTo(0, 0)
})
afterEach(() => {
for (const el of trash) el.remove()
trash.length = 0
window.scrollTo(0, 0)
})
// Run both engines on the same elements + equivalent middleware specs.
type Built = { own: Middleware[]; fui: FuiMiddleware[] }
async function bothPositions(
anchor: HTMLElement,
floating: HTMLElement,
mw: Built,
placement: Placement,
strategy: Strategy = 'absolute'
) {
const own = await ownComputePosition(anchor, floating, {
placement,
strategy,
middleware: mw.own,
dom
})
const fui = await fuiComputePosition(anchor, floating, {
placement,
strategy,
middleware: mw.fui
})
return { own, fui }
}
// Coords match to within a sub-pixel tolerance (both read the same DOM via
// getBoundingClientRect; any divergence > this is a real rects/clipping bug).
const TOL = 0.5
function assertSame(label: string, own: { x: number; y: number; placement: string }, fui: { x: number; y: number; placement: string }) {
expect(own.placement, `${label} · placement`).toBe(fui.placement)
expect(Math.abs(own.x - fui.x), `${label} · x (own=${own.x} fui=${fui.x})`).toBeLessThanOrEqual(TOL)
expect(Math.abs(own.y - fui.y), `${label} · y (own=${own.y} fui=${fui.y})`).toBeLessThanOrEqual(TOL)
}
const PLACEMENTS: Placement[] = [
'top',
'top-start',
'top-end',
'right',
'right-start',
'right-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'left-start',
'left-end'
]
describe('real-DOM parity — plain anchor in body, all placements', () => {
for (const placement of PLACEMENTS) {
it(placement, async () => {
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 }), ownFlip(), ownShift({ padding: 6 })],
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
}
const { own, fui } = await bothPositions(anchor, floating, mw, placement)
assertSame(`plain ${placement}`, own, fui)
})
}
})
describe('real-DOM parity — flip/shift forced at viewport edges', () => {
const SPOTS = [
{ name: 'top-left', left: 2, top: 2 },
{ name: 'top-right', left: -1, top: 2, fromRight: true },
{ name: 'bottom-left', left: 2, top: -1, fromBottom: true },
{ name: 'bottom-right', fromRight: true, fromBottom: true }
]
for (const placement of ['bottom', 'top', 'right', 'left'] as Placement[]) {
for (const spot of SPOTS) {
it(`${placement} @ ${spot.name}`, async () => {
const styles: Partial<CSSStyleDeclaration> = {
position: 'fixed',
width: '90px',
height: '34px'
}
if (spot.fromRight) styles.right = '2px'
else styles.left = `${spot.left}px`
if (spot.fromBottom) styles.bottom = '2px'
else styles.top = `${spot.top}px`
const anchor = box(styles)
const floating = box({ position: 'absolute', width: '220px', height: '150px' })
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(`edge ${placement}/${spot.name}`, own, fui)
})
}
}
})
describe('real-DOM parity — anchor inside a nested scroll container (clipping walk)', () => {
for (const placement of ['bottom', 'top', 'right', 'left'] as Placement[]) {
for (const scroll of [0, 60, 140]) {
it(`${placement} scrollTop=${scroll}`, async () => {
const scroller = box({
position: 'absolute',
left: '120px',
top: '120px',
width: '320px',
height: '220px',
overflow: 'auto',
border: '1px solid'
})
const tall = box({ height: '900px', width: '600px', position: 'relative' }, scroller)
const anchor = box(
{ position: 'absolute', left: '60px', top: '300px', width: '90px', height: '34px' },
tall
)
const floating = box({ position: 'absolute', width: '200px', height: '120px' })
scroller.scrollTop = scroll
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(`scroll ${placement}/${scroll}`, own, fui)
})
}
}
})
describe('real-DOM parity — transformed ancestor (containing block + offsetParent)', () => {
for (const placement of ['bottom', 'top-start', 'right-end'] as Placement[]) {
it(placement, async () => {
// A transform makes the wrapper a containing block AND the offsetParent
// for the absolutely-positioned floating element nested in it.
const wrapper = box({
position: 'relative',
transform: 'translateZ(0) scale(1)',
left: '200px',
top: '180px',
width: '400px',
height: '300px',
border: '1px solid'
})
const anchor = box(
{ position: 'absolute', left: '120px', top: '120px', 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(`transform ${placement}`, own, fui)
})
}
})
describe('real-DOM parity — CSS-scaled offsetParent', () => {
for (const placement of ['bottom', 'top-start', 'right'] as Placement[]) {
it(`scale(1.25) ${placement}`, async () => {
const wrapper = box({
position: 'relative',
transform: 'scale(1.25)',
transformOrigin: 'top left',
left: '160px',
top: '140px',
width: '420px',
height: '320px',
border: '1px solid'
})
const anchor = box(
{ position: 'absolute', left: '110px', top: '120px', 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(`scaled ${placement}`, own, fui)
})
}
})
describe('real-DOM parity — fixed strategy', () => {
for (const placement of ['bottom', 'top', 'right-start'] as Placement[]) {
it(placement, async () => {
const anchor = box({
position: 'fixed',
left: '500px',
top: '360px',
width: '90px',
height: '34px'
})
const floating = box({ position: 'fixed', 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, placement, 'fixed')
assertSame(`fixed ${placement}`, own, fui)
})
}
})
describe('real-DOM parity — scrolled page', () => {
for (const placement of ['bottom', 'top'] as Placement[]) {
it(placement, async () => {
const spacer = box({ height: '2000px', width: '1px', position: 'absolute', top: '0', left: '0' })
const anchor = box({
position: 'absolute',
left: '420px',
top: '1200px',
width: '90px',
height: '34px'
})
const floating = box({ position: 'absolute', width: '200px', height: '120px' })
void spacer
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, placement)
assertSame(`scrolled ${placement}`, own, fui)
})
}
})
// ── Audit-confirmed edge cases (offsetParent + clipping ancestor walk) ────────
describe('real-DOM parity — individual-transform containing block (fixed)', () => {
for (const prop of [
{ key: 'translate', value: '40px 20px' },
{ key: 'scale', value: '1.2' },
{ key: 'rotate', value: '6deg' }
]) {
it(`${prop.key}: ${prop.value}`, async () => {
// An individual transform (NOT `transform:`) makes the wrapper the
// containing block for the fixed floating element — so it becomes the
// offset basis. The engine must recognise translate/scale/rotate.
const wrapper = box({
position: 'relative',
left: '220px',
top: '180px',
width: '420px',
height: '320px',
border: '1px solid'
})
wrapper.style.setProperty(prop.key, prop.value)
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, 'bottom', 'fixed')
assertSame(`indiv ${prop.key}`, own, fui)
})
}
})
describe('real-DOM parity — anchor inside a static table cell (offsetParent skip)', () => {
it('td offsetParent skipped to positioned ancestor', async () => {
const outer = box({
position: 'relative',
left: '160px',
top: '160px',
width: '500px',
height: '300px',
border: '1px solid'
})
const table = document.createElement('table')
const tbody = document.createElement('tbody')
const tr = document.createElement('tr')
const td = document.createElement('td')
td.style.padding = '40px'
table.appendChild(tbody)
tbody.appendChild(tr)
tr.appendChild(td)
outer.appendChild(table)
const anchor = box(
{ position: 'absolute', left: '30px', top: '30px', width: '90px', height: '34px' },
td
)
const floating = box({ position: 'absolute', width: '200px', height: '120px' }, td)
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-cell', own, fui)
})
})
describe('real-DOM parity — fixed floating escaping a scroll container (clip drop)', () => {
for (const placement of ['bottom', 'top', 'right'] as Placement[]) {
it(`${placement} not clipped to the scroller`, async () => {
const scroller = box({
position: 'relative',
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
)
// Fixed → escapes the scroll container's clip; the engine must drop the
// scroller from the clipping-ancestor walk (else it flips/shifts early).
const floating = box({ position: 'fixed', width: '200px', height: '140px' }, scroller)
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-escape ${placement}`, own, fui)
})
}
})
describe('real-DOM parity — arrow + size in real layout', () => {
it('arrow centering (bottom) matches', async () => {
const anchor = box({
position: 'absolute',
left: '420px',
top: '300px',
width: '120px',
height: '36px'
})
const floating = box({ position: 'absolute', width: '220px', height: '140px' })
const arrowEl = box({ position: 'absolute', width: '12px', height: '12px' }, floating)
const { own, fui } = await bothPositions(
anchor,
floating,
{
own: [ownOffset({ mainAxis: 10 }), ownArrow({ element: arrowEl, padding: 6 })],
fui: [fuiOffset({ mainAxis: 10 }), fuiArrow({ element: arrowEl, padding: 6 })]
},
'bottom'
)
assertSame('arrow bottom', own, fui)
const oa = own.middlewareData.arrow as { x?: number }
const fa = fui.middlewareData.arrow as { x?: number }
expect(Math.abs((oa.x ?? 0) - (fa.x ?? 0)), 'arrow.x').toBeLessThanOrEqual(TOL)
})
it('size available space (bottom, tall floating) matches', async () => {
const anchor = box({
position: 'absolute',
left: '420px',
top: '560px',
width: '90px',
height: '34px'
})
const floating = box({ position: 'absolute', width: '200px', height: '400px' })
let ownAH = -1
let fuiAH = -1
await bothPositions(
anchor,
floating,
{
own: [
ownOffset({ mainAxis: 8 }),
ownSize({ padding: 0, apply: ({ availableHeight }) => (ownAH = availableHeight) })
],
fui: [
fuiOffset({ mainAxis: 8 }),
fuiSize({ padding: 0, apply: ({ availableHeight }) => (fuiAH = availableHeight) })
]
},
'bottom'
)
expect(Math.abs(ownAH - fuiAH), `availableHeight (own=${ownAH} fui=${fuiAH})`).toBeLessThanOrEqual(TOL)
})
})

@ -73,16 +73,18 @@ function platformOf(s: Scenario, arrowEl: object, offsetParent: object) {
getOffsetParent: async () => offsetParent,
getScale: async () => s.offsetScale ?? { x: 1, y: 1 },
isElement: async () => true,
convertOffsetParentRelativeRectToViewportRelativeRect: async ({
rect
}: {
rect: Rect
}) => ({
x: rect.x + (s.viewportDelta?.x ?? 0),
y: rect.y + (s.viewportDelta?.y ?? 0),
width: rect.width,
height: rect.height
}),
convertOffsetParentRelativeRectToViewportRelativeRect: async ({ rect }: { rect: Rect }) => {
// Mirror the real floating-ui convert: rect·scale + (the constant term,
// modelled here as viewportDelta = convert({0,0,0,0})).
const sx = s.offsetScale?.x ?? 1
const sy = s.offsetScale?.y ?? 1
return {
x: rect.x * sx + (s.viewportDelta?.x ?? 0),
y: rect.y * sy + (s.viewportDelta?.y ?? 0),
width: rect.width * sx,
height: rect.height * sy
}
},
isRTL: async () => s.rtl ?? false,
getDocumentElement: async () => ({})
}
@ -116,6 +118,117 @@ async function compare(
return { own, fui, ownCaptured, fuiCaptured }
}
// ── Comprehensive sweep helpers ───────────────────────────────────────────────
const PLACEMENTS: Placement[] = [
'top',
'top-start',
'top-end',
'right',
'right-start',
'right-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'left-start',
'left-end'
]
type OffsetSpec = number | { mainAxis?: number; crossAxis?: number; alignmentAxis?: number | null }
type ChainSpec = {
offset?: OffsetSpec
shift?: boolean
shiftCross?: boolean
limiter?: boolean
flip?: boolean
size?: boolean
arrow?: boolean
arrowPadding?: number
hide?: boolean
padding?: number
}
// Build the same middleware chain for either engine — the option shapes are
// identical, so one spec drives both. `filter(Boolean)` drops disabled entries.
function buildChain(
kind: 'own' | 'fui',
ctx: { arrowEl: object },
spec: ChainSpec
): unknown[] {
const F =
kind === 'own'
? {
offset: ownOffset,
shift: ownShift,
flip: ownFlip,
size: ownSize,
arrow: ownArrow,
hide: ownHide,
limitShift: ownLimitShift
}
: {
offset: fuiOffset,
shift: fuiShift,
flip: fuiFlip,
size: fuiSize,
arrow: fuiArrow,
hide: fuiHide,
limitShift: fuiLimitShift
}
const detect = { padding: spec.padding ?? 0 }
const arr: unknown[] = []
if (spec.offset !== undefined) arr.push(F.offset(spec.offset as never))
if (spec.shift)
arr.push(
F.shift({
mainAxis: true,
crossAxis: !!spec.shiftCross,
limiter: spec.limiter ? (F.limitShift() as never) : undefined,
...detect
} as never)
)
if (spec.flip) arr.push(F.flip({ ...detect } as never))
if (spec.size) arr.push(F.size({ ...detect, apply: () => {} } as never))
if (spec.arrow)
arr.push(F.arrow({ element: ctx.arrowEl as never, padding: spec.arrowPadding ?? 0 } as never))
if (spec.hide) arr.push(F.hide({ strategy: 'referenceHidden', ...detect } as never))
return arr.filter(Boolean)
}
type Result = { x: number; y: number; placement: string; middlewareData: Record<string, unknown> }
function numEq(label: string, a: number | undefined, b: number | undefined) {
if (a === undefined && b === undefined) return
expect(a, label).toBeCloseTo(b as number, 4)
}
// Assert the two engines agree on the full public API surface: coords, placement,
// and the middleware-data fields consumers read (arrow position + centering, hide).
function assertParity(
label: string,
own: Result,
fui: Result,
opts: { arrow?: boolean; hide?: boolean } = {}
) {
expect(own.placement, `${label} · placement`).toBe(fui.placement)
numEq(`${label} · x`, own.x, fui.x)
numEq(`${label} · y`, own.y, fui.y)
if (opts.arrow) {
const oa = (own.middlewareData.arrow ?? {}) as Record<string, number | undefined>
const fa = (fui.middlewareData.arrow ?? {}) as Record<string, number | undefined>
numEq(`${label} · arrow.x`, oa.x, fa.x)
numEq(`${label} · arrow.y`, oa.y, fa.y)
numEq(`${label} · arrow.centerOffset`, oa.centerOffset, fa.centerOffset)
numEq(`${label} · arrow.alignmentOffset`, oa.alignmentOffset ?? 0, fa.alignmentOffset ?? 0)
}
if (opts.hide) {
const oh = (own.middlewareData.hide ?? {}) as { referenceHidden?: boolean }
const fh = (fui.middlewareData.hide ?? {}) as { referenceHidden?: boolean }
expect(!!oh.referenceHidden, `${label} · hide.referenceHidden`).toBe(!!fh.referenceHidden)
}
}
describe('positioning engine ↔ @floating-ui parity', () => {
it('offset only — base placement, no collision', async () => {
const scenario: Scenario = {
@ -374,3 +487,345 @@ describe('positioning engine ↔ @floating-ui parity', () => {
expect(own.y).toBeCloseTo(fui.y, 5)
})
})
// ── Exhaustive matrix: every placement × edge position, full chain ────────────
// 12 placements × 7 reference positions that each stress a different viewport
// edge/corner. Each runs the full middleware stack (offset+shift+flip+size+
// arrow+hide) so flip decisions, shift clamps, arrow centering and hide all
// interact — and must agree with @floating-ui in every cell.
describe('parity matrix — 12 placements × edge positions (full chain)', () => {
const POSITIONS = [
{ name: 'center', x: 410, y: 350 },
{ name: 'near-top', x: 410, y: 6 },
{ name: 'near-bottom', x: 410, y: 760 },
{ name: 'near-left', x: 6, y: 350 },
{ name: 'near-right', x: 910, y: 350 },
{ name: 'corner-tl', x: 6, y: 6 },
{ name: 'corner-br', x: 910, y: 760 }
]
for (const placement of PLACEMENTS) {
for (const pos of POSITIONS) {
it(`${placement} @ ${pos.name}`, async () => {
const scenario: Scenario = {
reference: { x: pos.x, y: pos.y, width: 90, height: 32 },
floatingDims: { width: 180, height: 120 },
clip: VIEWPORT,
arrowDims: { width: 12, height: 12 }
}
const { own, fui } = await compare(scenario, placement, (kind, ctx) =>
buildChain(kind, ctx, {
offset: { mainAxis: 8 },
shift: true,
flip: true,
size: true,
arrow: true,
hide: true,
padding: 4
})
)
assertParity(`${placement}@${pos.name}`, own, fui, { arrow: true, hide: true })
})
}
}
})
// ── flip — forced past each edge, base + aligned placements ───────────────────
describe('parity — flip forced at each edge', () => {
const CASES: { placement: Placement; ref: Rect }[] = [
{ placement: 'bottom', ref: { x: 410, y: 740, width: 90, height: 32 } },
{ placement: 'bottom-start', ref: { x: 410, y: 740, width: 90, height: 32 } },
{ placement: 'bottom-end', ref: { x: 410, y: 740, width: 90, height: 32 } },
{ placement: 'top', ref: { x: 410, y: 8, width: 90, height: 32 } },
{ placement: 'top-start', ref: { x: 410, y: 8, width: 90, height: 32 } },
{ placement: 'right', ref: { x: 930, y: 350, width: 90, height: 32 } },
{ placement: 'right-end', ref: { x: 930, y: 350, width: 90, height: 32 } },
{ placement: 'left', ref: { x: 8, y: 350, width: 90, height: 32 } },
{ placement: 'left-start', ref: { x: 8, y: 350, width: 90, height: 32 } }
]
for (const c of CASES) {
it(`${c.placement} flips to fit`, async () => {
const scenario: Scenario = {
reference: c.ref,
floatingDims: { width: 200, height: 150 },
clip: VIEWPORT
}
const { own, fui } = await compare(scenario, c.placement, (kind, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 8 }, flip: true, padding: 0 })
)
assertParity(`flip ${c.placement}`, own, fui)
})
}
})
// ── shift — main axis + cross axis, with and without limitShift ───────────────
describe('parity — shift on both axes', () => {
const EDGES = [
{ name: 'right-edge', ref: { x: 930, y: 350 } },
{ name: 'left-edge', ref: { x: 4, y: 350 } },
{ name: 'top-edge', ref: { x: 410, y: 4 } },
{ name: 'bottom-edge', ref: { x: 410, y: 770 } }
]
for (const placement of ['bottom', 'top', 'right', 'left'] as Placement[]) {
for (const edge of EDGES) {
for (const cross of [false, true]) {
for (const limiter of [false, true]) {
it(`${placement} shift cross=${cross} limiter=${limiter} @ ${edge.name}`, async () => {
const scenario: Scenario = {
reference: { x: edge.ref.x, y: edge.ref.y, width: 90, height: 32 },
floatingDims: { width: 190, height: 130 },
clip: VIEWPORT
}
const { own, fui } = await compare(scenario, placement, (kind, ctx) =>
buildChain(kind, ctx, {
offset: { mainAxis: 8 },
shift: true,
shiftCross: cross,
limiter,
padding: 6
})
)
assertParity(`shift ${placement}/${edge.name}`, own, fui)
})
}
}
}
}
})
// ── arrow — centering across placements, small ref + large padding ────────────
describe('parity — arrow centering', () => {
for (const placement of PLACEMENTS) {
it(`arrow ${placement} (normal ref)`, async () => {
const scenario: Scenario = {
reference: { x: 410, y: 350, width: 100, height: 40 },
floatingDims: { width: 220, height: 140 },
clip: VIEWPORT,
arrowDims: { width: 14, height: 14 }
}
const { own, fui } = await compare(scenario, placement, (kind, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 10 }, arrow: true, arrowPadding: 6 })
)
assertParity(`arrow ${placement}`, own, fui, { arrow: true })
})
it(`arrow ${placement} (tiny ref, big padding → alignmentOffset)`, async () => {
const scenario: Scenario = {
reference: { x: 410, y: 350, width: 16, height: 16 },
floatingDims: { width: 240, height: 160 },
clip: VIEWPORT,
arrowDims: { width: 18, height: 18 }
}
const { own, fui } = await compare(scenario, placement, (kind, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 8 }, arrow: true, arrowPadding: 16 })
)
assertParity(`arrow tiny ${placement}`, own, fui, { arrow: true })
})
}
})
// ── size — overflow on each side, with/without a preceding shift ──────────────
describe('parity — size available space', () => {
const CASES = [
{ name: 'tall-overflow-bottom', placement: 'bottom' as Placement, ref: { x: 410, y: 600 }, dims: { width: 200, height: 360 } },
{ name: 'tall-overflow-top', placement: 'top' as Placement, ref: { x: 410, y: 200 }, dims: { width: 200, height: 360 } },
{ name: 'wide-overflow-right', placement: 'right' as Placement, ref: { x: 700, y: 350 }, dims: { width: 420, height: 160 } },
{ name: 'wide-overflow-left', placement: 'left' as Placement, ref: { x: 300, y: 350 }, dims: { width: 420, height: 160 } }
]
for (const c of CASES) {
for (const withShift of [false, true]) {
it(`${c.name} shift=${withShift}`, async () => {
const scenario: Scenario = {
reference: { x: c.ref.x, y: c.ref.y, width: 90, height: 32 },
floatingDims: c.dims,
clip: VIEWPORT
}
let ownAW = 0,
ownAH = 0,
fuiAW = 0,
fuiAH = 0
const { own, fui } = await compare(scenario, c.placement, (kind, ctx) => {
const F =
kind === 'own'
? { offset: ownOffset, shift: ownShift, size: ownSize }
: { offset: fuiOffset, shift: fuiShift, size: fuiSize }
const apply = (a: { availableWidth: number; availableHeight: number }) => {
if (kind === 'own') {
ownAW = a.availableWidth
ownAH = a.availableHeight
} else {
fuiAW = a.availableWidth
fuiAH = a.availableHeight
}
}
return [
F.offset({ mainAxis: 8 } as never),
withShift
? F.shift({ mainAxis: true, crossAxis: true, padding: 0 } as never)
: undefined,
F.size({ padding: 0, apply } as never)
].filter(Boolean)
})
assertParity(`size ${c.name}`, own, fui)
expect(ownAW, `${c.name} availableWidth`).toBeCloseTo(fuiAW, 4)
expect(ownAH, `${c.name} availableHeight`).toBeCloseTo(fuiAH, 4)
})
}
}
})
// ── hide — reference inside / partially / fully outside the clip ──────────────
describe('parity — hide referenceHidden across clip positions', () => {
const CASES = [
{ name: 'fully-inside', ref: { x: 410, y: 350 } },
{ name: 'past-bottom', ref: { x: 410, y: 1200 } },
{ name: 'past-top', ref: { x: 410, y: -200 } },
{ name: 'past-right', ref: { x: 1400, y: 350 } },
{ name: 'past-left', ref: { x: -300, y: 350 } },
{ name: 'half-clipped-bottom', ref: { x: 410, y: 785 } }
]
for (const c of CASES) {
it(`hide ${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, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 8 }, hide: true, padding: 0 })
)
assertParity(`hide ${c.name}`, own, fui, { hide: true })
})
}
})
// ── limit / extreme cases ─────────────────────────────────────────────────────
describe('parity — extreme & limit cases', () => {
it('floating larger than the clip in both dimensions', async () => {
const scenario: Scenario = {
reference: { x: 480, y: 380, width: 40, height: 40 },
floatingDims: { width: 1200, height: 1000 },
clip: VIEWPORT
}
const { own, fui } = await compare(scenario, 'bottom', (kind, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 8 }, shift: true, flip: true, size: true, padding: 0 })
)
assertParity('oversized floating', own, fui)
})
it('reference larger than the viewport', async () => {
const scenario: Scenario = {
reference: { x: -100, y: -50, width: 1200, height: 900 },
floatingDims: { width: 200, height: 120 },
clip: VIEWPORT
}
const { own, fui } = await compare(scenario, 'bottom-start', (kind, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 8 }, shift: true, flip: true, padding: 0 })
)
assertParity('oversized reference', own, fui)
})
it('zero-size reference', async () => {
const scenario: Scenario = {
reference: { x: 500, y: 400, width: 0, height: 0 },
floatingDims: { width: 200, height: 120 },
clip: VIEWPORT,
arrowDims: { width: 10, height: 10 }
}
const { own, fui } = await compare(scenario, 'bottom', (kind, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 8 }, arrow: true, shift: true, flip: true, padding: 0 })
)
assertParity('zero-size ref', own, fui, { arrow: true })
})
it('fractional sub-pixel rects', async () => {
const scenario: Scenario = {
reference: { x: 333.337, y: 211.119, width: 87.51, height: 29.73 },
floatingDims: { width: 199.27, height: 117.91 },
clip: { x: 0, y: 0, width: 987.3, height: 743.6 },
arrowDims: { width: 11.4, height: 11.4 }
}
const { own, fui } = await compare(scenario, 'bottom-end', (kind, ctx) =>
buildChain(kind, ctx, {
offset: { mainAxis: 7.5, alignmentAxis: 3.25 },
shift: true,
flip: true,
arrow: true,
padding: 4
})
)
assertParity('fractional', own, fui, { arrow: true })
})
it('negative collisionPadding (overflow tolerance)', async () => {
const scenario: Scenario = {
reference: { x: 410, y: 740, width: 90, height: 32 },
floatingDims: { width: 200, height: 150 },
clip: VIEWPORT
}
const { own, fui } = await compare(scenario, 'bottom', (kind, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 8 }, flip: true, shift: true, padding: -20 })
)
assertParity('negative padding', own, fui)
})
it('large offset pushing past the edge', async () => {
const scenario: Scenario = {
reference: { x: 410, y: 350, width: 90, height: 32 },
floatingDims: { width: 180, height: 120 },
clip: VIEWPORT
}
const { own, fui } = await compare(scenario, 'bottom', (kind, ctx) =>
buildChain(kind, ctx, { offset: { mainAxis: 500, crossAxis: 300 }, shift: true, flip: true, padding: 0 })
)
assertParity('large offset', own, fui)
})
})
// ── viewportDelta (scroll) + offsetScale (scaled offsetParent) + rtl ──────────
describe('parity — coordinate-space variants (scroll · scale · rtl)', () => {
const DELTAS: (Coords | undefined)[] = [undefined, { x: -250, y: -600 }, { x: 120, y: 80 }]
const SCALES: (Coords | undefined)[] = [undefined, { x: 1.25, y: 1.25 }, { x: 0.8, y: 1.1 }]
let i = 0
for (const viewportDelta of DELTAS) {
for (const offsetScale of SCALES) {
for (const rtl of [false, true]) {
i++
const label = `delta=${viewportDelta ? `${viewportDelta.x},${viewportDelta.y}` : '0'} scale=${offsetScale ? `${offsetScale.x},${offsetScale.y}` : '1'} rtl=${rtl}`
it(`#${i} ${label}`, async () => {
const scenario: Scenario = {
reference: { x: 620, y: 540, width: 110, height: 34 },
floatingDims: { width: 210, height: 150 },
clip: { x: 0, y: 0, width: 1000, height: 760 },
viewportDelta,
offsetScale,
rtl,
arrowDims: { width: 12, height: 12 }
}
const { own, fui } = await compare(
scenario,
rtl ? 'bottom-start' : 'bottom-end',
(kind, ctx) =>
buildChain(kind, ctx, {
offset: { mainAxis: 8, alignmentAxis: 6 },
shift: true,
shiftCross: true,
flip: true,
size: true,
arrow: true,
hide: true,
padding: 6
})
)
assertParity(label, own, fui, { arrow: true, hide: true })
})
}
}
}
})

@ -44,12 +44,16 @@ export function detectOverflow(
elementContext === 'floating'
? { x, y, width: rects.floating.width, height: rects.floating.height }
: rects.reference
// …converted to viewport coords purely via the precomputed delta.
// …converted to viewport coords purely via the precomputed delta. This is the
// linearised form of floating-ui's `convert(rect) = rect·scale + delta`, where
// `viewportDelta = convert({0,0,0,0})` carries the (offsets − scroll·scale)
// term and `offsetScale` carries the per-rect scale (1,1 unless the
// offsetParent is CSS-scaled).
const elementClientRect = rectToClientRect({
x: elementRect.x + viewportDelta.x,
y: elementRect.y + viewportDelta.y,
width: elementRect.width,
height: elementRect.height
x: elementRect.x * offsetScale.x + viewportDelta.x,
y: elementRect.y * offsetScale.y + viewportDelta.y,
width: elementRect.width * offsetScale.x,
height: elementRect.height * offsetScale.y
})
return {

@ -92,18 +92,64 @@ function getNodeScroll(el: Element | Window): Coords {
return { x: (el as Element).scrollLeft, y: (el as Element).scrollTop }
}
/** Does the element establish a containing block for fixed/absolute descendants? */
/** 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 {
const leftScroll = getNodeScroll(element).x
if (!rect) return getBoundingClientRect(getDocumentElement(element), win).x + leftScroll
return rect.x + leftScroll
}
/** The documentElement's own offset within the page (≈0 normally; non-zero with
* a left scrollbar or a transformed `<html>`). Mirrors floating-ui's
* `getHTMLOffset` — the term that keeps window-offsetParent coords exact. */
function getHTMLOffset(documentElement: Element, scroll: Coords, win: Window): Coords {
const htmlRect = getBoundingClientRect(documentElement, win)
return {
x: htmlRect.x + scroll.x - getWindowScrollBarX(documentElement, win, htmlRect),
y: htmlRect.y + scroll.y
}
}
let isWebKitValue: boolean | undefined
/** Whether the engine is WebKit (Safari). Cached; mirrors floating-ui. WebKit
* treats `filter`/`backdrop-filter` differently for containing blocks, and gates
* the visual-viewport offset. */
export function isWebKit(win: Window): boolean {
if (isWebKitValue === undefined) {
const css = (win as Window & typeof globalThis).CSS
isWebKitValue =
typeof css !== 'undefined' &&
typeof css.supports === 'function' &&
css.supports('-webkit-backdrop-filter', 'none')
}
return isWebKitValue
}
function isTableElement(el: Element): boolean {
return /^(table|td|th)$/.test(getNodeName(el))
}
const WILL_CHANGE_RE = /transform|translate|scale|rotate|perspective|filter/
const CONTAIN_RE = /paint|layout|strict|content/
const isNotNone = (v: string | undefined): boolean => !!v && v !== 'none'
/** Does the element establish a containing block for fixed/absolute descendants?
* Mirrors floating-ui exactly: the individual transforms (`translate`/`scale`/
* `rotate`) count, `filter`/`backdrop-filter` only on non-WebKit, and (per the
* shipped floating-ui) `container-type` does NOT. */
export function isContainingBlock(el: Element, win: Window): boolean {
const css = getComputed(el, win)
const willChange = css.willChange
return (
css.transform !== 'none' ||
css.perspective !== 'none' ||
(css.containerType ? css.containerType !== 'normal' : false) ||
(css.backdropFilter ? css.backdropFilter !== 'none' : false) ||
(css.filter ? css.filter !== 'none' : false) ||
['transform', 'perspective', 'filter'].some((v) => willChange.includes(v)) ||
['paint', 'layout', 'strict', 'content'].some((v) => (css.contain || '').includes(v))
isNotNone(css.transform) ||
isNotNone(css.translate) ||
isNotNone(css.scale) ||
isNotNone(css.rotate) ||
isNotNone(css.perspective) ||
(!isWebKit(win) && (isNotNone(css.backdropFilter) || isNotNone(css.filter))) ||
WILL_CHANGE_RE.test(css.willChange || '') ||
CONTAIN_RE.test(css.contain || '')
)
}
@ -135,11 +181,7 @@ export function getOffsetParent(el: Element, win: Window): Element | Window {
if (!isHTMLElement(el)) return win
let offsetParent = getTrueOffsetParent(el, win)
while (
offsetParent &&
getNodeName(offsetParent) === 'table' &&
isStaticPositioned(offsetParent, win)
) {
while (offsetParent && isTableElement(offsetParent) && isStaticPositioned(offsetParent, win)) {
offsetParent = getTrueOffsetParent(offsetParent, win)
}
@ -182,9 +224,18 @@ function getRectRelativeToOffsetParent(
}
}
const htmlOffset =
documentElement && !isOffsetParentAnElement && !isFixed
? getHTMLOffset(documentElement, scroll, win)
: createCoords(0)
// Viewport rect → offsetParent-relative: ADD the scroll, SUBTRACT the
// offsetParent's own offset (matches floating-ui exactly). The earlier
// `- scroll + offsets` had both signs inverted — correct only at scroll 0
// with a window offsetParent, wrong for scrolled pages / positioned parents.
return {
x: rect.x - scroll.x + offsets.x,
y: rect.y - scroll.y + offsets.y,
x: rect.x + scroll.x - offsets.x - htmlOffset.x,
y: rect.y + scroll.y - offsets.y - htmlOffset.y,
width: rect.width,
height: rect.height
}
@ -221,7 +272,15 @@ export function getElementRects(
/**
* Convert an offset-parent-relative rect (the floating element at its proposed
* coords) into viewport coordinates — the space `detectOverflow` compares the
* clipping rect in. Inverse of `getRectRelativeToOffsetParent`.
* clipping rect in. Inverse of `getRectRelativeToOffsetParent`; mirrors
* floating-ui exactly, including the offsetParent scale and the htmlOffset term.
*
* The earlier version early-returned for a window offsetParent (`offsetParent
* === win`) and used inverted signs, so on a scrolled page it left the rect in
* document space while the clipping rect was in viewport space — `detectOverflow`
* then compared two different coordinate frames. The gate must be
* `offsetParent === documentElement` (the only no-op case), and a window
* offsetParent must still fold in the page scroll.
*/
export function convertOffsetParentRelativeRectToViewportRelativeRect(
rect: Rect,
@ -229,29 +288,45 @@ export function convertOffsetParentRelativeRectToViewportRelativeRect(
strategy: Strategy,
win: Window
): Rect {
const isOffsetParentAnElement = isHTMLElement(offsetParent)
const documentElement = getDocumentElement(offsetParent)
const isFixed = strategy === 'fixed'
const documentElement = getDocumentElement(offsetParent)
if (offsetParent === win || (!isOffsetParentAnElement && !isFixed)) {
if (offsetParent === documentElement) {
return rect
}
let scroll: Coords = createCoords(0)
let offsets: Coords = createCoords(0)
if (isOffsetParentAnElement && !isFixed) {
let scale: Coords = createCoords(1)
const offsets: Coords = createCoords(0)
const isOffsetParentAnElement = isHTMLElement(offsetParent)
if (isOffsetParentAnElement || (!isOffsetParentAnElement && !isFixed)) {
if (getNodeName(offsetParent) !== 'body' || isScrollable(documentElement, win)) {
scroll = getNodeScroll(offsetParent)
}
const offsetRect = getBoundingClientRect(offsetParent, win, true)
offsets = { x: offsetRect.x + offsetParent.clientLeft, y: offsetRect.y + offsetParent.clientTop }
if (isOffsetParentAnElement) {
// RAW offsetParent rect here (includeScale=false) — convert works in the
// scaled viewport frame, so the offsetParent's own scaled position is the
// origin. (getRect, by contrast, divides by the offsetParent scale because
// it produces coords in the offsetParent's unscaled local frame.) Mixing
// these up under-counts the offset by the scale factor on scaled parents.
const offsetRect = getBoundingClientRect(offsetParent, win)
scale = getScale(offsetParent, win)
offsets.x = offsetRect.x + offsetParent.clientLeft
offsets.y = offsetRect.y + offsetParent.clientTop
}
}
const htmlOffset =
documentElement && !isOffsetParentAnElement && !isFixed
? getHTMLOffset(documentElement, scroll, win)
: createCoords(0)
return {
width: rect.width,
height: rect.height,
x: rect.x + scroll.x - offsets.x,
y: rect.y + scroll.y - offsets.y
width: rect.width * scale.x,
height: rect.height * scale.y,
x: rect.x * scale.x - scroll.x * scale.x + offsets.x + htmlOffset.x,
y: rect.y * scale.y - scroll.y * scale.y + offsets.y + htmlOffset.y
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.