From a2500e9e31aec1ad60f68aacd3948509105b9d7e Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 30 Jun 2026 12:46:54 +0200 Subject: [PATCH] =?UTF-8?q?test(floating):=20dual-system=20parity=20suites?= =?UTF-8?q?=20=E2=80=94=20find=20+=20fix=209=20rects/clipping=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/uix/soma/layers/floating/CONTINUE.md | 60 ++- .../soma/layers/floating/engine/clipping.ts | 89 +++- .../floating/engine/engine-dom.svelte.test.ts | 443 ++++++++++++++++ .../layers/floating/engine/engine.test.ts | 475 +++++++++++++++++- .../soma/layers/floating/engine/overflow.ts | 14 +- src/uix/soma/layers/floating/engine/rects.ts | 131 +++-- 6 files changed, 1148 insertions(+), 64 deletions(-) create mode 100644 src/uix/soma/layers/floating/engine/engine-dom.svelte.test.ts diff --git a/src/uix/soma/layers/floating/CONTINUE.md b/src/uix/soma/layers/floating/CONTINUE.md index 5b3175d32..d82eaa004 100644 --- a/src/uix/soma/layers/floating/CONTINUE.md +++ b/src/uix/soma/layers/floating/CONTINUE.md @@ -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) diff --git a/src/uix/soma/layers/floating/engine/clipping.ts b/src/uix/soma/layers/floating/engine/clipping.ts index 97b71b9bf..2c678be6a 100644 --- a/src/uix/soma/layers/floating/engine/clipping.ts +++ b/src/uix/soma/layers/floating/engine/clipping.ts @@ -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 `` + * 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) 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 new file mode 100644 index 000000000..43b488518 --- /dev/null +++ b/src/uix/soma/layers/floating/engine/engine-dom.svelte.test.ts @@ -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, 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 = { + 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) + }) +}) diff --git a/src/uix/soma/layers/floating/engine/engine.test.ts b/src/uix/soma/layers/floating/engine/engine.test.ts index da7d7f8f4..b0f8b1a65 100644 --- a/src/uix/soma/layers/floating/engine/engine.test.ts +++ b/src/uix/soma/layers/floating/engine/engine.test.ts @@ -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 } + +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 + const fa = (fui.middlewareData.arrow ?? {}) as Record + 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 }) + }) + } + } + } +}) diff --git a/src/uix/soma/layers/floating/engine/overflow.ts b/src/uix/soma/layers/floating/engine/overflow.ts index 5c93f60cf..32b08d83c 100644 --- a/src/uix/soma/layers/floating/engine/overflow.ts +++ b/src/uix/soma/layers/floating/engine/overflow.ts @@ -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 { diff --git a/src/uix/soma/layers/floating/engine/rects.ts b/src/uix/soma/layers/floating/engine/rects.ts index cb9484a52..f95b8b8ab 100644 --- a/src/uix/soma/layers/floating/engine/rects.ts +++ b/src/uix/soma/layers/floating/engine/rects.ts @@ -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 ``). 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 } }