From 4438012198bc61ee5e8b24c9b77835d9ac357f22 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 30 Jun 2026 02:24:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(floating):=20P1=20complete=20=E2=80=94=20d?= =?UTF-8?q?etectOverflow=20+=20middleware=20+=20compute=20+=20autoUpdate?= =?UTF-8?q?=20(flag-gated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the in-house positioning engine that replaces @floating-ui. The DOM-read core (rects + clipping ancestor-walk) was already committed; this adds the rest: - engine/overflow.ts — detectOverflow, PURE over the pre-read clipping + viewportDelta/offsetScale (no DOM read in the middleware phase). - engine/middleware/{offset,shift,flip,arrow,size,hide,limit-shift}.ts — each a factory with @floating-ui's call signatures + index barrel. - engine/compute.ts — one dom.measure read phase → the pure, exported runMiddleware loop (base coords → chain → flip/arrow reset, capped at 50). - engine/auto-update.ts — ancestor scroll/resize via $adom listen, element resize via ResizeObserver, optional rAF loop; no raw window/getBoundingClientRect. - MiddlewareState gains viewportDelta/offsetScale/rtl (precomputed upfront so the middleware stay pure); MiddlewareReturn.reset widened to boolean | {placement}. - geometry.ts gains getOppositeAxis/getAlignmentSides/getExpandedPlacements/ getOppositeAxisPlacements; clipping.ts exports getOverflowAncestors. - Middleware de-vendored from @floating-ui: engine/types owns it, ../types re-exports it as the public surface. - engine/flag.ts — USE_OWN_ENGINE A/B switch, default OFF (runtime still @floating-ui). use-floating + floating.svelte branch on it and thread `dom`. Verified: engine/engine.test.ts — 13-case parity guard proving runMiddleware is pixel-identical to @floating-ui/core on synthetic rects (offset, flip with/without overflow, shift, limitShift, arrow centerOffset + alignmentOffset, size, hide, full chain with viewportDelta/scroll, rtl). npm run check at baseline (0 new). Note: the engine/ dir keeps its P1 no-semicolon style (pre-existing), so it does not match the repo's prettier semi:true; left as-is to avoid reformatting the committed P1 files. P2/P3 (broad foreground-browser A/B) + P4 (@floating-ui removal) remain — see engine CONTINUE.md. Co-Authored-By: Claude Opus 4.8 --- src/uix/soma/layers/floating/CONTINUE.md | 163 +++++--- .../layers/floating/engine/auto-update.ts | 93 +++++ .../soma/layers/floating/engine/clipping.ts | 5 +- .../soma/layers/floating/engine/compute.ts | 205 ++++++++++ .../layers/floating/engine/engine.test.ts | 376 ++++++++++++++++++ src/uix/soma/layers/floating/engine/flag.ts | 6 + .../soma/layers/floating/engine/geometry.ts | 77 ++++ .../floating/engine/middleware/arrow.ts | 78 ++++ .../layers/floating/engine/middleware/flip.ts | 148 +++++++ .../layers/floating/engine/middleware/hide.ts | 58 +++ .../floating/engine/middleware/index.ts | 11 + .../floating/engine/middleware/limit-shift.ts | 69 ++++ .../floating/engine/middleware/offset.ts | 63 +++ .../floating/engine/middleware/shift.ts | 70 ++++ .../layers/floating/engine/middleware/size.ts | 78 ++++ .../soma/layers/floating/engine/overflow.ts | 63 +++ src/uix/soma/layers/floating/engine/types.ts | 25 +- .../soma/layers/floating/floating.svelte.ts | 68 +++- src/uix/soma/layers/floating/types.ts | 10 +- .../layers/floating/use-floating.svelte.ts | 31 +- 20 files changed, 1602 insertions(+), 95 deletions(-) create mode 100644 src/uix/soma/layers/floating/engine/auto-update.ts create mode 100644 src/uix/soma/layers/floating/engine/compute.ts create mode 100644 src/uix/soma/layers/floating/engine/engine.test.ts create mode 100644 src/uix/soma/layers/floating/engine/flag.ts create mode 100644 src/uix/soma/layers/floating/engine/middleware/arrow.ts create mode 100644 src/uix/soma/layers/floating/engine/middleware/flip.ts create mode 100644 src/uix/soma/layers/floating/engine/middleware/hide.ts create mode 100644 src/uix/soma/layers/floating/engine/middleware/index.ts create mode 100644 src/uix/soma/layers/floating/engine/middleware/limit-shift.ts create mode 100644 src/uix/soma/layers/floating/engine/middleware/offset.ts create mode 100644 src/uix/soma/layers/floating/engine/middleware/shift.ts create mode 100644 src/uix/soma/layers/floating/engine/middleware/size.ts create mode 100644 src/uix/soma/layers/floating/engine/overflow.ts diff --git a/src/uix/soma/layers/floating/CONTINUE.md b/src/uix/soma/layers/floating/CONTINUE.md index 829f61b9d..5b3175d32 100644 --- a/src/uix/soma/layers/floating/CONTINUE.md +++ b/src/uix/soma/layers/floating/CONTINUE.md @@ -1,8 +1,14 @@ # CONTINUE — Removing `@floating-ui`, building our own positioning layer -> **Status: P1 in progress (~half).** The hard part (DOM-read + clipping ancestor-walk) -> is built, committed and compiling. The middleware + orchestration + wiring remain. -> Branch: `alpha-0.1-sec-dom`. Read this cold to resume. +> **Status: P1 COMPLETE — flag-gated, math-verified.** The whole engine is built +> and compiling: DOM-read + clipping ancestor-walk + `detectOverflow` + all 7 +> middleware + `compute` (read phase + pure `runMiddleware` loop) + `autoUpdate` + +> the `Middleware` de-vendor + the `USE_OWN_ENGINE` flag wiring. A 13-case parity +> test (`engine/engine.test.ts`) proves the engine is **pixel-identical to +> `@floating-ui`** on synthetic rects. The flag is **OFF by default** (runtime +> still `@floating-ui`); flip `engine/flag.ts` to A/B in a browser. What remains +> is broad foreground-browser A/B across every overlay (P2/P3) then the actual +> `@floating-ui` deletion (P4). Branch: `alpha-0.1-sec-dom`. Read this cold to resume. ## Why @@ -82,73 +88,98 @@ eidos `@supports` block — both must agree. **Module map** (`engine/`): ``` types.ts ✅ internal types (Rect/Coords/ElementRects/ClippingContext/ - MiddlewareState/MiddlewareReturn/Middleware/ComputePosition*) -geometry.ts ✅ pure placement/coords math + MiddlewareState[+viewportDelta/offsetScale/rtl]/MiddlewareReturn + [reset: boolean | {placement}]/Middleware/ComputePosition*) +geometry.ts ✅ pure placement/coords math (+getOppositeAxis/getAlignmentSides/ + getExpandedPlacements/getOppositeAxisPlacements) rects.ts ✅ offsetParent + scale-aware rects + viewport conversion -clipping.ts ✅ getClippingRect ancestor-walk (option 2) +clipping.ts ✅ getClippingRect ancestor-walk (option 2) [+exports getOverflowAncestors] supports.ts ✅ supportsCssAnchor (dispatcher gate) -overflow.ts ⬜ detectOverflow — PURE, over the pre-read clipping + viewportDelta -middleware/ ⬜ offset · shift · flip · arrow · size · hide · limit-shift -compute.ts ⬜ the read phase + the middleware loop (with flip's `reset`) -auto-update.ts⬜ scroll/resize/rAF via $adom +overflow.ts ✅ detectOverflow — PURE, over pre-read clipping + viewportDelta + offsetScale +middleware/ ✅ offset · shift · flip · arrow · size · hide · limit-shift (+index barrel) +compute.ts ✅ read phase (computePosition) → pure loop (runMiddleware, exported for tests) +auto-update.ts✅ scroll/resize ($adom listen) + ResizeObserver + rAF loop, no raw window +flag.ts ✅ USE_OWN_ENGINE (A/B switch; OFF = @floating-ui) +engine.test.ts✅ 13-case parity guard: runMiddleware ↔ @floating-ui/core, identical ``` -## Remaining (ordered — do these next) - -1. **Adjust `MiddlewareState`** (`engine/types.ts`): add `viewportDelta: Coords` - (precomputed in the read phase so `detectOverflow` converts purely); the middleware - are pure so they don't need `dom`. The read phase computes `viewportDelta` via - `convertOffsetParentRelativeRectToViewportRelativeRect({x:0,y:0,width:0,height:0}, - offsetParent, strategy, win)` → `{x,y}`. -2. **`engine/overflow.ts`** — `detectOverflow(state, { padding?, elementContext? })`: - PURE. `elementRect` (floating at `state.x/y` + `rects.floating` dims, or - `rects.reference`) → convert to viewport via `viewportDelta` → compare to - `state.clipping[elementContext]` (already viewport coords) → `SideObject`. Use - `getPaddingObject` + `rectToClientRect`. -3. **`engine/middleware/*.ts`** — each is a factory returning `Middleware`, matching - floating-ui's call signatures (so `floating.svelte.ts`'s array works with our - factories under the flag): - - `offset({ mainAxis?, alignmentAxis? } | number)` — pure (shift coords along axes). - - `shift({ mainAxis?, crossAxis?, limiter?, boundary?, padding? })` — detectOverflow, - slide within; `limiter` from `limitShift()`. - - `flip({ boundary?, padding?, ... })` — detectOverflow on side; if overflows, return - `{ reset: { placement: getOppositePlacement(...) } }`. - - `arrow({ element, padding? })` — pure; center on reference, clamp by padding, emit - `{ data: { x?, y?, centerOffset } }`. Uses `state.arrowDimensions`. - - `size({ boundary?, padding?, apply })` — detectOverflow → availableWidth/Height → - call `apply({ availableWidth, availableHeight, rects, ... })`. - - `hide({ strategy: 'referenceHidden', boundary?, padding? })` — detectOverflow on the - REFERENCE (`elementContext:'reference'`) → `{ data: { referenceHidden } }`. - - `limitShift(options?)` — the shift limiter for `sticky:'partial'`. - - **`transformOrigin` is ALREADY ours** (floating.svelte.ts:391-429) — keep verbatim. -4. **`engine/compute.ts`** — `computePosition(reference, floating, config): Promise<...>`: - - Extract `boundary`/`padding` from the middleware options (uniform in our usage — - `floating.svelte`'s `this.detectOverflowOptions`; take the first middleware that - declares a `boundary`, default `'clippingAncestors'`). Detect if `hide` is present - (→ also read the reference clipping). - - `await` ONE read phase: `new Promise(res => dom.measure(() => res(readAll()), floating))` - reading `getElementRects` + `getClippingRect(floating, boundary)` + - (hide ? `getClippingRect(reference, boundary)`) + arrow dims + `viewportDelta`. - - Run the middleware loop: base coords from `computeCoordsFromPlacement`, then each - middleware in order; on `reset: { placement }` recompute base coords for the new - placement and restart the loop (cap iterations ~50, like floating-ui). Merge - `data` into `middlewareData[name]`. - - Return `{ x, y, placement, strategy, middlewareData }`. -5. **`engine/auto-update.ts`** — `autoUpdate(reference, floating, update, { animationFrame?, dom })`: - scroll + resize listeners via `dom.listen`; element resize via `$adom` ResizeObserver - (or runed `ElementSize`); `animationFrame` loop via `dom.requestFrame`. Returns cleanup. - **No raw `window`/`getBoundingClientRect`.** -6. **De-vendor `Middleware`** — now that `engine/types.ts` defines `MiddlewareState`/ - `Return`/`Middleware`, move `Middleware` out of `@floating-ui` into `../types` - (re-export from `engine/types`). Update the `type Middleware` imports in `types.ts`, - `use-floating.svelte.ts`, `floating.svelte.ts`. -7. **Wire the `USE_OWN_ENGINE` flag** — a module const. When true: - - `use-floating.svelte.ts:59` — call `computePosition` from `./engine/compute` (thread - `dom` from `FloatingProvider.opts.dom`). Keep the `.then(...)` shape (compute is async - via the read-phase rAF — matches today). - - `floating.svelte.ts:143-177` — build the middleware array from `./engine/middleware`; - swap `autoUpdate` (line 294) for `./engine/auto-update`. - Keep `@floating-ui` as the default (flag off) for A/B until verified. +## Done this pass (P1 finished — all 7 items below landed) + +1. ✅ **`MiddlewareState`** gained `viewportDelta` + `offsetScale` + `rtl` (precomputed + in the read phase so the middleware are PURE; `dom` dropped from the state). + `MiddlewareReturn.reset` widened to `boolean | { placement? }` (arrow's bare reset). +2. ✅ **`engine/overflow.ts`** — `detectOverflow(state, { padding?, elementContext?, altBoundary? })`: + PURE. element rect → `+ viewportDelta` → `rectToClientRect` → compare to + `state.clipping[context]` → `SideObject` / `offsetScale`. `context = altBoundary ? + swap(elementContext) : elementContext`. +3. ✅ **`engine/middleware/*.ts`** — offset · shift · flip · arrow · size · hide · + limit-shift, each a factory returning `Middleware` with floating-ui's call signatures. + `transformOrigin` stays in `floating.svelte.ts` (ours already), now typed to our + `Middleware`. +4. ✅ **`engine/compute.ts`** — `computePosition` does ONE `dom.measure` read phase + (`getElementRects` + `getClippingRect(floating)` + optional reference clip + arrow + dims + `viewportDelta`/`offsetScale`/`rtl`), then hands a `ReadSnapshot` to the pure, + exported **`runMiddleware`** loop (base coords → chain → `reset` restart, cap 50). + `boundary` extracted from the first middleware that declares one (`[]` = explicit + viewport; default `'clippingAncestors'`). Reference clip read only when `hide` / + `altBoundary` present. +5. ✅ **`engine/auto-update.ts`** — ancestor scroll/resize via `dom.listen` + (reusing `clipping.getOverflowAncestors`), element resize via `dom.observeResize`, + `animationFrame` loop via `dom.requestFrame`. No raw `window`/`getBoundingClientRect` + (the per-frame ref read runs at frame-start = post-layout). `observeMove` + IntersectionObserver path intentionally omitted (out of CONTINUE scope). +6. ✅ **De-vendored `Middleware`** — `engine/types.ts` owns it; `../types.ts` re-exports + it (public surface). `types.ts` / `use-floating.svelte.ts` / `floating.svelte.ts` + repointed off `@floating-ui/dom`. +7. ✅ **Wired `USE_OWN_ENGINE`** (`engine/flag.ts`, default **false**): + - `use-floating.svelte.ts` branches `computePosition` (own vs fui) + threads `dom` + (new `UseFloatingOptions.dom`, fed from `FloatingProvider.opts.dom`). + - `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 + +- **Math parity guard — `engine/engine.test.ts` (13 cases, all 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. + +### ⚠️ Preview-tool caveat for the broad browser A/B (read before P2/P3) + +The own engine's read phase uses `dom.measure` → `requestAnimationFrame`. The +**preview tool's tab is backgrounded** (`document.hidden === true`), so rAF is paused +and the engine never positions there. floating-ui's `computePosition` is microtask- +based, so the flag-off baseline works in the hidden tab but flag-on does not. Two ways +through it next time: (a) drive a foreground browser, or (b) inject a setTimeout-based +rAF shim from the page (`window.requestAnimationFrame = cb => setTimeout(() => cb(performance.now()), 0)`) +BEFORE opening the overlay — that's how this pass confirmed the engine end-to-end. +Also: editing `flag.ts` only HMR-updates leaf `.svelte` components, leaving the floating +layer modules stale/inconsistent — do a **full cache-busted reload** (`location.href = +url + '?cb=' + Date.now()`), not `location.reload()`, after flipping the flag. Finally, +the popover demo's trigger shifts ~8px between position-time and measure-time, so +**absolute** A/B numbers are noisy — compare wrapper-relative-to-trigger (gap + +centering) measured in the same frame, or trust the unit parity guard. + +## Remaining (next sessions) + +- **P2/P3 — broad foreground-browser A/B** (flag on): walk every overlay demo (popover, + tooltip, dropdown/context/sub menus, menubar, nav-menu, select, combobox, the 5 + pickers, dialog, drawer, link-preview) across all 12 placements + flip-at-edges + + shift + arrow + matchAnchorWidth + sticky-scroll + virtual anchor (context-menu) + + safe-polygon (link-preview) + **nested-scroll/transform clipping** (the option-2 win). + Use the rAF-shim or a foreground browser (see caveat). Zero `[Violation] Forced reflow`. +- **P4 — delete `@floating-ui`** (after P2/P3): see the removal surface below. Flip the + flag default to true (or inline the own path + drop the flag), strip the `fui*` imports + + the loose factory shim + the `as unknown as FuiMiddleware[]` cast, then + `npm uninstall @floating-ui/core @floating-ui/dom`. Verify `grep -r "@floating-ui" + src/ web/` → ZERO. ## API-compat checklist (the consumer × feature matrix to preserve) diff --git a/src/uix/soma/layers/floating/engine/auto-update.ts b/src/uix/soma/layers/floating/engine/auto-update.ts new file mode 100644 index 000000000..d29da42b0 --- /dev/null +++ b/src/uix/soma/layers/floating/engine/auto-update.ts @@ -0,0 +1,93 @@ +// autoUpdate — re-runs `update` whenever the reference or floating element could +// have moved: ancestor scroll / resize, element resize (ResizeObserver), and an +// optional per-frame loop. Everything goes through `$adom` (iframe / popup safe, +// no raw `window`). Reimplemented from floating-ui's `autoUpdate` (spec); the +// `observeMove` IntersectionObserver layout-shift path is intentionally omitted +// (CONTINUE.md scope: scroll + resize + rAF). + +import { isElement } from '$adom' +import type { ActiveDom } from '$adom' +import type { Measurable } from '../types' +import { getOverflowAncestors } from './clipping' + +export type AutoUpdateOptions = { + ancestorScroll?: boolean + ancestorResize?: boolean + elementResize?: boolean + animationFrame?: boolean + dom: ActiveDom +} + +function rectsEqual(a: DOMRect, b: DOMRect): boolean { + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height +} + +export function autoUpdate( + reference: Measurable | HTMLElement, + floating: HTMLElement, + update: () => void, + options: AutoUpdateOptions +): () => void { + const { + ancestorScroll = true, + ancestorResize = true, + elementResize = true, + animationFrame = false, + dom + } = options + const win = dom.getWindow(floating) + const referenceEl = isElement(reference) ? (reference as Element) : null + + const cleanups: Array<() => void> = [] + + const ancestors = + ancestorScroll || ancestorResize + ? [ + ...(referenceEl ? getOverflowAncestors(referenceEl, win) : []), + ...getOverflowAncestors(floating, win) + ] + : [] + + for (const ancestor of ancestors) { + if (ancestorScroll) { + cleanups.push(dom.listen(ancestor as EventTarget, 'scroll', update, { passive: true })) + } + if (ancestorResize) { + cleanups.push(dom.listen(ancestor as EventTarget, 'resize', update)) + } + } + + // Element resize. In `animationFrame` mode the reference is tracked by the + // frame loop instead (its movement, not just its size, matters). The soma + // `size` middleware never resizes the floating element synchronously, so the + // floating-ui unobserve/reobserve loop-guard isn't needed here. + if (elementResize) { + if (referenceEl && !animationFrame) cleanups.push(dom.observeResize(referenceEl, update)) + cleanups.push(dom.observeResize(floating, update)) + } + + // Per-frame movement tracking. Reading the reference rect at the START of a + // rAF callback is post-layout (frame-start, before any write) — compliant + // with the no-sync-after-write rule. + let frameId = 0 + let stopped = false + if (animationFrame) { + let prevRect = reference.getBoundingClientRect() + const loop = () => { + if (stopped) return + const nextRect = reference.getBoundingClientRect() + if (!rectsEqual(prevRect, nextRect)) update() + prevRect = nextRect + frameId = dom.requestFrame(loop, floating) + } + frameId = dom.requestFrame(loop, floating) + } + + update() + + return () => { + stopped = true + if (frameId) dom.cancelFrame(frameId, floating) + for (const cleanup of cleanups) cleanup() + } +} diff --git a/src/uix/soma/layers/floating/engine/clipping.ts b/src/uix/soma/layers/floating/engine/clipping.ts index 265357a6e..97b71b9bf 100644 --- a/src/uix/soma/layers/floating/engine/clipping.ts +++ b/src/uix/soma/layers/floating/engine/clipping.ts @@ -38,8 +38,9 @@ function getNearestOverflowAncestor(node: Node, win: Window): HTMLElement { return getNearestOverflowAncestor(parent, win) } -/** The element's overflow/clip ancestors + the window (single document). */ -function getOverflowAncestors( +/** The element's overflow/clip ancestors + the window (single document). + * Exported for `auto-update.ts` (it attaches scroll/resize listeners to each). */ +export function getOverflowAncestors( node: Node, win: Window, list: (Element | Window)[] = [] diff --git a/src/uix/soma/layers/floating/engine/compute.ts b/src/uix/soma/layers/floating/engine/compute.ts new file mode 100644 index 000000000..024ae7fb6 --- /dev/null +++ b/src/uix/soma/layers/floating/engine/compute.ts @@ -0,0 +1,205 @@ +// computePosition — the orchestrator. One `dom.measure` read phase reads +// EVERYTHING upfront (reference rect, floating dims, offsetParent, the clipping +// ancestor-walk, arrow dims, and the offsetParent→viewport conversion +// constants); the middleware chain then runs PURELY over that snapshot. This is +// the read-phase model (CONTINUE.md): one coalesced rAF, no forced reflow. +// Reimplemented from floating-ui's `computePosition` (spec), our idioms. + +import { getDocumentElement, isElement } from '$adom' +import type { Placement, Strategy } from '../placement' +import type { Measurable, MiddlewareData } from '../types' +import { getClippingRect } from './clipping' +import { computeCoordsFromPlacement } from './geometry' +import { + convertOffsetParentRelativeRectToViewportRelativeRect, + getDimensions, + getElementRects, + getScale +} from './rects' +import type { + ClippingContext, + ComputePositionConfig, + ComputePositionReturn, + Coords, + Dimensions, + ElementRects, + FloatingElements, + Middleware, + MiddlewareState +} from './types' + +// Bound on flip / arrow resets, matching floating-ui — guards against a chain +// that oscillates between two placements forever. +const MAX_RESET_COUNT = 50 + +type Boundary = 'clippingAncestors' | Element[] + +/** The boundary the read phase walks — the first middleware that declares one + * (uniform across shift / flip / size / hide in our usage). `[]` means an + * explicit viewport-only clip; absent means the full ancestor walk. */ +function extractBoundary(middleware: readonly Middleware[]): Boundary { + for (const m of middleware) { + const opts = m.options as { boundary?: unknown } | undefined + if (opts && opts.boundary !== undefined) { + return opts.boundary as Boundary + } + } + return 'clippingAncestors' +} + +function extractArrowElement(middleware: readonly Middleware[]): Element | undefined { + for (const m of middleware) { + if (m.name === 'arrow') { + const opts = m.options as { element?: Element } | undefined + if (opts?.element) return opts.element + } + } + return undefined +} + +function needsReferenceClip(middleware: readonly Middleware[]): boolean { + return middleware.some( + (m) => m.name === 'hide' || (m.options as { altBoundary?: boolean } | undefined)?.altBoundary + ) +} + +export function computePosition( + reference: Measurable | HTMLElement, + floating: HTMLElement, + config: ComputePositionConfig +): Promise { + const { placement = 'bottom', strategy = 'absolute', middleware = [], dom } = config + const win = dom.getWindow(floating) + + return new Promise((resolve) => { + // Single coalesced post-layout read — every DOM read the chain needs. + dom.measure(() => { + resolve(runChain(reference, floating, placement, strategy, middleware, win)) + }, floating) + }) +} + +function runChain( + reference: Measurable | HTMLElement, + floating: HTMLElement, + placement: Placement, + strategy: Strategy, + middleware: readonly Middleware[], + win: Window +): ComputePositionReturn { + const elements = { reference, floating } + const { rects, offsetParent } = getElementRects(elements, strategy, win) + + const boundary = extractBoundary(middleware) + const clippingFloating = getClippingRect(floating, boundary, strategy, win) + const referenceClipElement = isElement(reference) + ? (reference as Element) + : getDocumentElement(floating) + const clipping: ClippingContext = { + floating: clippingFloating, + reference: needsReferenceClip(middleware) + ? getClippingRect(referenceClipElement, boundary, strategy, win) + : undefined + } + + const arrowElement = extractArrowElement(middleware) + const arrowDimensions = arrowElement ? getDimensions(arrowElement, win) : undefined + + const delta = convertOffsetParentRelativeRectToViewportRelativeRect( + { x: 0, y: 0, width: 0, height: 0 }, + offsetParent, + strategy, + win + ) + const viewportDelta: Coords = { x: delta.x, y: delta.y } + const offsetScale: Coords = isElement(offsetParent) + ? getScale(offsetParent, win) + : { x: 1, y: 1 } + const rtl = win.getComputedStyle(floating).direction === 'rtl' + + return runMiddleware(placement, strategy, middleware, { + rects, + elements, + clipping, + arrowDimensions, + viewportDelta, + offsetScale, + rtl + }) +} + +/** + * The fully-read snapshot the middleware chain runs over — everything + * `runMiddleware` needs, with NO DOM access. `runChain` populates it from the + * read phase; tests populate it with synthetic rects to exercise the pure math. + */ +export type ReadSnapshot = { + readonly rects: ElementRects + readonly elements: FloatingElements + readonly clipping: ClippingContext + readonly arrowDimensions?: Dimensions + readonly viewportDelta: Coords + readonly offsetScale: Coords + readonly rtl: boolean +} + +/** + * The pure middleware loop — base coords from the placement, then each + * middleware in order; `reset` restarts the chain (flip recomputes base coords + * for the new placement, capped at {@link MAX_RESET_COUNT}). No DOM: operates + * solely on the read snapshot. Exported so the engine's math can be verified + * against `@floating-ui` with synthetic inputs. + */ +export function runMiddleware( + placement: Placement, + strategy: Strategy, + middleware: readonly Middleware[], + snapshot: ReadSnapshot +): ComputePositionReturn { + const { rects, elements, clipping, arrowDimensions, viewportDelta, offsetScale, rtl } = snapshot + + let { x, y } = computeCoordsFromPlacement(rects, placement, rtl) + let statefulPlacement = placement + let resetCount = 0 + const middlewareData: MiddlewareData = {} + + for (let i = 0; i < middleware.length; i++) { + const current = middleware[i] + if (!current) continue + + const state: MiddlewareState = { + x, + y, + initialPlacement: placement, + placement: statefulPlacement, + strategy, + middlewareData, + rects, + elements, + clipping, + arrowDimensions, + viewportDelta, + offsetScale, + rtl + } + + const { x: nextX, y: nextY, data, reset } = current.fn(state) + x = nextX ?? x + y = nextY ?? y + middlewareData[current.name] = { + ...(middlewareData[current.name] as Record | undefined), + ...data + } + + if (reset && resetCount < MAX_RESET_COUNT) { + resetCount++ + if (typeof reset === 'object') { + if (reset.placement) statefulPlacement = reset.placement + ;({ x, y } = computeCoordsFromPlacement(rects, statefulPlacement, rtl)) + } + i = -1 + } + } + + return { x, y, placement: statefulPlacement, strategy, middlewareData } +} diff --git a/src/uix/soma/layers/floating/engine/engine.test.ts b/src/uix/soma/layers/floating/engine/engine.test.ts new file mode 100644 index 000000000..da7d7f8f4 --- /dev/null +++ b/src/uix/soma/layers/floating/engine/engine.test.ts @@ -0,0 +1,376 @@ +// Parity guard: the in-house positioning engine must match `@floating-ui` to the +// pixel. We feed BOTH the same synthetic rects (no DOM, no layout, no rAF) and +// assert identical coords / placement / middleware data. Our `runMiddleware` +// (the real loop, extracted from `computePosition`) runs over a hand-built read +// snapshot; floating-ui's `computePosition` runs over a synthetic `platform` +// that returns the SAME rects. Any drift in offset / shift / flip / arrow / size +// / hide / detectOverflow / the reset loop fails here. + +import { describe, expect, it } from 'vitest' +import { + arrow as fuiArrow, + computePosition as fuiComputePosition, + flip as fuiFlip, + hide as fuiHide, + limitShift as fuiLimitShift, + offset as fuiOffset, + shift as fuiShift, + size as fuiSize +} from '@floating-ui/core' + +import { runMiddleware, type ReadSnapshot } from './compute' +import { + arrow as ownArrow, + flip as ownFlip, + hide as ownHide, + limitShift as ownLimitShift, + offset as ownOffset, + shift as ownShift, + size as ownSize +} from './middleware' +import type { Coords, Dimensions, Middleware, Rect } from './types' +import type { Placement } from '../placement' + +type Scenario = { + reference: Rect + floatingDims: Dimensions + clip: Rect + viewportDelta?: Coords + offsetScale?: Coords + rtl?: boolean + arrowDims?: Dimensions +} + +const VIEWPORT: Rect = { x: 0, y: 0, width: 1000, height: 800 } + +function snapshotOf(s: Scenario): ReadSnapshot { + return { + rects: { + reference: s.reference, + floating: { x: 0, y: 0, width: s.floatingDims.width, height: s.floatingDims.height } + }, + elements: { reference: {} as never, floating: {} as never }, + clipping: { floating: s.clip, reference: s.clip }, + arrowDimensions: s.arrowDims, + viewportDelta: s.viewportDelta ?? { x: 0, y: 0 }, + offsetScale: s.offsetScale ?? { x: 1, y: 1 }, + rtl: s.rtl ?? false + } +} + +// A floating-ui `platform` that returns the scenario's synthetic rects — the +// twin of `snapshotOf` so both engines see identical geometry. The arrow element +// and offsetParent are sentinel objects routed by identity. +function platformOf(s: Scenario, arrowEl: object, offsetParent: object) { + return { + getElementRects: async () => ({ + reference: s.reference, + floating: { x: 0, y: 0, width: s.floatingDims.width, height: s.floatingDims.height } + }), + getClippingRect: async () => ({ ...s.clip }), + getDimensions: async (el: unknown) => + el === arrowEl ? (s.arrowDims ?? { width: 0, height: 0 }) : { ...s.floatingDims }, + 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 + }), + isRTL: async () => s.rtl ?? false, + getDocumentElement: async () => ({}) + } +} + +const EPS = 0.01 + +async function compare( + scenario: Scenario, + placement: Placement, + build: (kind: 'own' | 'fui', ctx: { arrowEl: object; captured: Record }) => unknown[] +) { + const arrowEl = {} + const offsetParent = {} + + const ownCaptured: Record = {} + const ownMw = build('own', { arrowEl, captured: ownCaptured }) as Middleware[] + const own = runMiddleware(placement, 'absolute', ownMw, snapshotOf(scenario)) + + const fuiCaptured: Record = {} + const fuiMw = build('fui', { arrowEl, captured: fuiCaptured }) + const fui = await fuiComputePosition({} as never, {} as never, { + placement: placement as never, + strategy: 'absolute', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + middleware: fuiMw as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + platform: platformOf(scenario, arrowEl, offsetParent) as any + }) + + return { own, fui, ownCaptured, fuiCaptured } +} + +describe('positioning engine ↔ @floating-ui parity', () => { + it('offset only — base placement, no collision', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 })].filter(Boolean) + ) + expect(own.x).toBeCloseTo(fui.x, 5) + expect(own.y).toBeCloseTo(fui.y, 5) + expect(own.placement).toBe(fui.placement) + }) + + it('offset with alignmentAxis on an aligned placement', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT + } + for (const placement of ['bottom-start', 'bottom-end', 'right-start'] as Placement[]) { + const { own, fui } = await compare(scenario, placement, (kind) => + [ + kind === 'own' + ? ownOffset({ mainAxis: 8, alignmentAxis: 12 }) + : fuiOffset({ mainAxis: 8, alignmentAxis: 12 }) + ].filter(Boolean) + ) + expect(own.x).toBeCloseTo(fui.x, 5) + expect(own.y).toBeCloseTo(fui.y, 5) + } + }) + + it('flip — bottom overflows, flips to top', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 700, width: 80, height: 30 }, + floatingDims: { width: 200, height: 120 }, + clip: { x: 0, y: 0, width: 1000, height: 760 } + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' ? ownFlip({ padding: 0 }) : fuiFlip({ padding: 0 }) + ].filter(Boolean) + ) + expect(own.placement).toBe('top') + expect(own.placement).toBe(fui.placement) + expect(own.x).toBeCloseTo(fui.x, 5) + expect(own.y).toBeCloseTo(fui.y, 5) + }) + + it('flip — fits, no flip', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 120 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' ? ownFlip({ padding: 0 }) : fuiFlip({ padding: 0 }) + ].filter(Boolean) + ) + expect(own.placement).toBe('bottom') + expect(own.placement).toBe(fui.placement) + expect(own.y).toBeCloseTo(fui.y, 5) + }) + + it('shift — reference near the right edge, slides into view', async () => { + const scenario: Scenario = { + reference: { x: 900, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownShift({ mainAxis: true, crossAxis: false, padding: 0 }) + : fuiShift({ mainAxis: true, crossAxis: false, padding: 0 }) + ].filter(Boolean) + ) + expect(own.x).toBeCloseTo(fui.x, 5) + expect(own.y).toBeCloseTo(fui.y, 5) + // Right edge of the floating element clamped inside the viewport. + expect(own.x + scenario.floatingDims.width).toBeLessThanOrEqual(1000 + EPS) + }) + + it('shift with limitShift (sticky: partial)', async () => { + const scenario: Scenario = { + reference: { x: 900, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownShift({ mainAxis: true, crossAxis: false, limiter: ownLimitShift(), padding: 0 }) + : fuiShift({ mainAxis: true, crossAxis: false, limiter: fuiLimitShift(), padding: 0 }) + ].filter(Boolean) + ) + expect(own.x).toBeCloseTo(fui.x, 5) + expect(own.y).toBeCloseTo(fui.y, 5) + }) + + it('arrow — centers + reports centerOffset', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT, + arrowDims: { width: 12, height: 12 } + } + const { own, fui } = await compare(scenario, 'bottom', (kind, ctx) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownArrow({ element: ctx.arrowEl as Element, padding: 4 }) + : fuiArrow({ element: ctx.arrowEl as never, padding: 4 }) + ].filter(Boolean) + ) + const oa = own.middlewareData.arrow as { x?: number; centerOffset?: number } + const fa = fui.middlewareData.arrow as { x?: number; centerOffset?: number } + expect(oa.x).toBeCloseTo(fa.x as number, 5) + expect(oa.centerOffset).toBeCloseTo(fa.centerOffset as number, 5) + }) + + it('arrow — aligned placement (alignmentOffset path)', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 100, width: 20, height: 20 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT, + arrowDims: { width: 14, height: 14 } + } + const { own, fui } = await compare(scenario, 'bottom-start', (kind, ctx) => + [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownArrow({ element: ctx.arrowEl as Element, padding: 10 }) + : fuiArrow({ element: ctx.arrowEl as never, padding: 10 }) + ].filter(Boolean) + ) + expect(own.x).toBeCloseTo(fui.x, 5) + expect(own.y).toBeCloseTo(fui.y, 5) + const oa = own.middlewareData.arrow as { centerOffset?: number; alignmentOffset?: number } + const fa = fui.middlewareData.arrow as { centerOffset?: number; alignmentOffset?: number } + expect(oa.centerOffset).toBeCloseTo(fa.centerOffset as number, 5) + expect(oa.alignmentOffset ?? 0).toBeCloseTo(fa.alignmentOffset ?? 0, 5) + }) + + it('size — available width/height handed to apply', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 600, width: 80, height: 30 }, + floatingDims: { width: 200, height: 400 }, + clip: { x: 0, y: 0, width: 1000, height: 800 } + } + const { ownCaptured, fuiCaptured } = await compare(scenario, 'bottom', (kind, ctx) => { + const apply = (args: { availableWidth: number; availableHeight: number }) => { + ctx.captured.availableWidth = args.availableWidth + ctx.captured.availableHeight = args.availableHeight + } + return [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' ? ownSize({ padding: 0, apply }) : fuiSize({ padding: 0, apply }) + ].filter(Boolean) + }) + expect(ownCaptured.availableWidth).toBeCloseTo(fuiCaptured.availableWidth, 5) + expect(ownCaptured.availableHeight).toBeCloseTo(fuiCaptured.availableHeight, 5) + }) + + it('hide — reference fully outside the clip → referenceHidden', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 1200, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: { x: 0, y: 0, width: 1000, height: 800 } + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' + ? ownHide({ strategy: 'referenceHidden', padding: 0 }) + : fuiHide({ strategy: 'referenceHidden', padding: 0 }) + ].filter(Boolean) + ) + const oh = own.middlewareData.hide as { referenceHidden?: boolean } + const fh = fui.middlewareData.hide as { referenceHidden?: boolean } + expect(oh.referenceHidden).toBe(true) + expect(oh.referenceHidden).toBe(fh.referenceHidden) + }) + + it('hide — reference visible → not hidden', async () => { + const scenario: Scenario = { + reference: { x: 100, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT + } + const { own, fui } = await compare(scenario, 'bottom', (kind) => + [ + kind === 'own' + ? ownHide({ strategy: 'referenceHidden', padding: 0 }) + : fuiHide({ strategy: 'referenceHidden', padding: 0 }) + ].filter(Boolean) + ) + const oh = own.middlewareData.hide as { referenceHidden?: boolean } + const fh = fui.middlewareData.hide as { referenceHidden?: boolean } + expect(oh.referenceHidden).toBe(false) + expect(oh.referenceHidden).toBe(fh.referenceHidden) + }) + + it('full chain — offset + shift + flip + size + arrow, with viewportDelta + scroll', async () => { + const scenario: Scenario = { + reference: { x: 850, y: 700, width: 120, height: 36 }, + floatingDims: { width: 240, height: 160 }, + clip: { x: 0, y: 0, width: 1000, height: 760 }, + viewportDelta: { x: -40, y: -120 }, + arrowDims: { width: 12, height: 12 } + } + const { own, fui } = await compare(scenario, 'bottom', (kind, ctx) => { + const detect = { padding: 8 } + const apply = () => {} + return [ + kind === 'own' ? ownOffset({ mainAxis: 8 }) : fuiOffset({ mainAxis: 8 }), + kind === 'own' + ? ownShift({ mainAxis: true, crossAxis: false, ...detect }) + : fuiShift({ mainAxis: true, crossAxis: false, ...detect }), + kind === 'own' ? ownFlip({ ...detect }) : fuiFlip({ ...detect }), + kind === 'own' ? ownSize({ ...detect, apply }) : fuiSize({ ...detect, apply }), + kind === 'own' + ? ownArrow({ element: ctx.arrowEl as Element, padding: 4 }) + : fuiArrow({ element: ctx.arrowEl as never, padding: 4 }) + ].filter(Boolean) + }) + expect(own.placement).toBe(fui.placement) + expect(own.x).toBeCloseTo(fui.x, 5) + expect(own.y).toBeCloseTo(fui.y, 5) + const oa = own.middlewareData.arrow as { x?: number; y?: number; centerOffset?: number } + const fa = fui.middlewareData.arrow as { x?: number; y?: number; centerOffset?: number } + expect(oa.x ?? oa.y).toBeCloseTo((fa.x ?? fa.y) as number, 5) + }) + + it('rtl — aligned offset mirrors', async () => { + const scenario: Scenario = { + reference: { x: 400, y: 100, width: 80, height: 30 }, + floatingDims: { width: 200, height: 100 }, + clip: VIEWPORT, + rtl: true + } + const { own, fui } = await compare(scenario, 'bottom-start', (kind) => + [ + kind === 'own' + ? ownOffset({ mainAxis: 8, alignmentAxis: 12 }) + : fuiOffset({ mainAxis: 8, alignmentAxis: 12 }) + ].filter(Boolean) + ) + expect(own.x).toBeCloseTo(fui.x, 5) + expect(own.y).toBeCloseTo(fui.y, 5) + }) +}) diff --git a/src/uix/soma/layers/floating/engine/flag.ts b/src/uix/soma/layers/floating/engine/flag.ts new file mode 100644 index 000000000..a18ea370c --- /dev/null +++ b/src/uix/soma/layers/floating/engine/flag.ts @@ -0,0 +1,6 @@ +// A/B switch for the in-house positioning engine. While `false`, the floating +// layer keeps using `@floating-ui` (the default during the migration's +// verification window). Flip to `true` to drive positioning with `engine/` +// (computePosition + middleware + autoUpdate). P4 deletes the flag, the +// `@floating-ui` imports, and the dependency once the engine is verified. +export const USE_OWN_ENGINE = false diff --git a/src/uix/soma/layers/floating/engine/geometry.ts b/src/uix/soma/layers/floating/engine/geometry.ts index e64d7328e..8a15984a6 100644 --- a/src/uix/soma/layers/floating/engine/geometry.ts +++ b/src/uix/soma/layers/floating/engine/geometry.ts @@ -7,6 +7,11 @@ import type { Axis, Coords, ElementRects, Length, Padding, SideObject } from './ const OPPOSITE_ALIGN: Record<'start' | 'end', 'start' | 'end'> = { start: 'end', end: 'start' } +/** The axis perpendicular to `axis`. */ +export function getOppositeAxis(axis: Axis): Axis { + return axis === 'x' ? 'y' : 'x' +} + export function getSide(placement: Placement): Side { return placement.split('-')[0] as Side } @@ -43,6 +48,78 @@ export function getOppositeAlignmentPlacement(placement: Placement): Placement { return `${getSide(placement)}-${OPPOSITE_ALIGN[align]}` as Placement } +/** + * The two sides on the alignment axis that flip's cross-axis check compares — + * the side the floating element aligns toward and its opposite. Mirrors + * floating-ui's `getAlignmentSides`. + */ +export function getAlignmentSides(placement: Placement, rects: ElementRects, rtl = false): [Side, Side] { + const align = getAlignment(placement) + const alignmentAxis = getAlignmentAxis(placement) + const length = getAxisLength(alignmentAxis) + let mainAlignmentSide: Side = + alignmentAxis === 'x' + ? align === (rtl ? 'end' : 'start') + ? 'right' + : 'left' + : align === 'start' + ? 'bottom' + : 'top' + if (rects.reference[length] > rects.floating[length]) { + mainAlignmentSide = OPPOSITE_SIDE[mainAlignmentSide] + } + return [mainAlignmentSide, OPPOSITE_SIDE[mainAlignmentSide]] +} + +/** The opposite-side placement plus the two alignment variants — flip's default + * fallback list for an aligned initial placement. */ +export function getExpandedPlacements(placement: Placement): Placement[] { + const opposite = getOppositePlacement(placement) + return [ + getOppositeAlignmentPlacement(placement), + opposite, + getOppositeAlignmentPlacement(opposite) + ] +} + +const LR: Side[] = ['left', 'right'] +const RL: Side[] = ['right', 'left'] +const TB: Side[] = ['top', 'bottom'] +const BT: Side[] = ['bottom', 'top'] + +function getSideList(side: Side, isStart: boolean, rtl: boolean): Side[] { + switch (side) { + case 'top': + case 'bottom': + if (rtl) return isStart ? RL : LR + return isStart ? LR : RL + case 'left': + case 'right': + return isStart ? TB : BT + default: + return [] + } +} + +/** The perpendicular-axis fallback placements — flip's `fallbackAxisSideDirection` + * path (unused by the soma layer today, kept for engine parity). */ +export function getOppositeAxisPlacements( + placement: Placement, + flipAlignment: boolean, + direction: 'start' | 'end', + rtl: boolean +): Placement[] { + const align = getAlignment(placement) + let list: string[] = getSideList(getSide(placement), direction === 'start', rtl) + if (align) { + list = list.map((side) => `${side}-${align}`) + if (flipAlignment) { + list = list.concat(list.map((p) => getOppositeAlignmentPlacement(p as Placement))) + } + } + return list as Placement[] +} + export function getPaddingObject(padding: Padding): SideObject { return typeof padding === 'number' ? { top: padding, right: padding, bottom: padding, left: padding } diff --git a/src/uix/soma/layers/floating/engine/middleware/arrow.ts b/src/uix/soma/layers/floating/engine/middleware/arrow.ts new file mode 100644 index 000000000..34376cdea --- /dev/null +++ b/src/uix/soma/layers/floating/engine/middleware/arrow.ts @@ -0,0 +1,78 @@ +// arrow — positions an inner arrow element so it points at the reference center, +// clamped within the floating element by `padding`. PURE: the arrow's dimensions +// are pre-read in the read phase (`state.arrowDimensions`); the container size is +// taken from `rects.floating` (the borderless floating wrapper — a faithful +// stand-in for floating-ui's `offsetParent.clientWidth/Height`). Reimplemented +// from floating-ui's `arrow` (spec). + +import { getAlignment, getAlignmentAxis, getAxisLength, getPaddingObject } from '../geometry' +import type { Middleware, Padding } from '../types' + +export type ArrowOptions = { element: Element; padding?: Padding } + +const { min } = Math +function clamp(start: number, value: number, end: number): number { + return Math.max(start, Math.min(value, end)) +} + +export function arrow(options: ArrowOptions): Middleware { + return { + name: 'arrow', + options, + fn(state) { + const { x, y, placement, rects, middlewareData, arrowDimensions } = state + const { padding = 0 } = options + if (!arrowDimensions) return {} + + const paddingObject = getPaddingObject(padding) + const coords = { x, y } + const axis = getAlignmentAxis(placement) + const length = getAxisLength(axis) + const isYAxis = axis === 'y' + const minProp = isYAxis ? 'top' : 'left' + const maxProp = isYAxis ? 'bottom' : 'right' + + const clientSize = rects.floating[length] + const endDiff = + rects.reference[length] + rects.reference[axis] - coords[axis] - rects.floating[length] + const startDiff = coords[axis] - rects.reference[axis] + const centerToReference = endDiff / 2 - startDiff / 2 + + // Clamp the padding so the arrow stays centered if it's large. + const largestPossiblePadding = clientSize / 2 - arrowDimensions[length] / 2 - 1 + const minPadding = min(paddingObject[minProp], largestPossiblePadding) + const maxPadding = min(paddingObject[maxProp], largestPossiblePadding) + + const minValue = minPadding + const maxValue = clientSize - arrowDimensions[length] - maxPadding + const center = clientSize / 2 - arrowDimensions[length] / 2 + centerToReference + const offset = clamp(minValue, center, maxValue) + + // If the reference is so small the arrow points at nothing for an aligned + // placement, nudge the floating element itself (one reset). + const shouldAddOffset = + !middlewareData.arrow && + getAlignment(placement) != null && + center !== offset && + rects.reference[length] / 2 - + (center < minValue ? minPadding : maxPadding) - + arrowDimensions[length] / 2 < + 0 + const alignmentOffset = shouldAddOffset + ? center < minValue + ? center - minValue + : center - maxValue + : 0 + + return { + [axis]: coords[axis] + alignmentOffset, + data: { + [axis]: offset, + centerOffset: center - offset - alignmentOffset, + ...(shouldAddOffset && { alignmentOffset }) + }, + reset: shouldAddOffset + } + } + } +} diff --git a/src/uix/soma/layers/floating/engine/middleware/flip.ts b/src/uix/soma/layers/floating/engine/middleware/flip.ts new file mode 100644 index 000000000..7cfabce94 --- /dev/null +++ b/src/uix/soma/layers/floating/engine/middleware/flip.ts @@ -0,0 +1,148 @@ +// flip — when the preferred placement overflows, swap to a fallback placement +// that fits (the side / alignment opposite). PURE (detectOverflow reads +// pre-walked clipping). Returns `{ reset: { placement } }` to restart the chain. +// Reimplemented from floating-ui's `flip` (spec). + +import type { Placement } from '../../placement' +import { + getAlignmentSides, + getExpandedPlacements, + getOppositeAxisPlacements, + getOppositePlacement, + getSide, + getSideAxis +} from '../geometry' +import { detectOverflow, type DetectOverflowOptions } from '../overflow' +import type { Middleware } from '../types' + +export type FlipOptions = DetectOverflowOptions & { + mainAxis?: boolean + crossAxis?: boolean | 'alignment' + fallbackPlacements?: Placement[] + fallbackStrategy?: 'bestFit' | 'initialPlacement' + fallbackAxisSideDirection?: 'none' | 'start' | 'end' + flipAlignment?: boolean +} + +type OverflowRecord = { placement: Placement; overflows: number[] } + +export function flip(options: FlipOptions = {}): Middleware { + const { + mainAxis: checkMainAxis = true, + crossAxis: checkCrossAxis = true, + fallbackPlacements: specifiedFallbackPlacements, + fallbackStrategy = 'bestFit', + fallbackAxisSideDirection = 'none', + flipAlignment = true, + ...detectOptions + } = options + return { + name: 'flip', + options, + fn(state) { + const { placement, middlewareData, rects, initialPlacement, rtl } = state + + // If the arrow added an alignment offset, flip already settled; skip. + const arrowData = middlewareData.arrow as { alignmentOffset?: number } | undefined + if (arrowData?.alignmentOffset) return {} + + const side = getSide(placement) + const initialSideAxis = getSideAxis(initialPlacement) + const isBasePlacement = getSide(initialPlacement) === initialPlacement + + const fallbackPlacements = + specifiedFallbackPlacements || + (isBasePlacement || !flipAlignment + ? [getOppositePlacement(initialPlacement)] + : getExpandedPlacements(initialPlacement)) + const hasFallbackAxisSideDirection = fallbackAxisSideDirection !== 'none' + if (!specifiedFallbackPlacements && hasFallbackAxisSideDirection) { + fallbackPlacements.push( + ...getOppositeAxisPlacements( + initialPlacement, + flipAlignment, + fallbackAxisSideDirection, + rtl + ) + ) + } + const placements = [initialPlacement, ...fallbackPlacements] + const overflow = detectOverflow(state, detectOptions) + + const flipData = middlewareData.flip as + | { index?: number; overflows?: OverflowRecord[] } + | undefined + const overflows: number[] = [] + let overflowsData = flipData?.overflows || [] + + if (checkMainAxis) overflows.push(overflow[side]) + if (checkCrossAxis) { + const sides = getAlignmentSides(placement, rects, rtl) + overflows.push(overflow[sides[0]], overflow[sides[1]]) + } + overflowsData = [...overflowsData, { placement, overflows }] + + // One or more sides overflows — try the next placement. + if (!overflows.every((s) => s <= 0)) { + const nextIndex = (flipData?.index || 0) + 1 + const nextPlacement = placements[nextIndex] + if (nextPlacement) { + const ignoreCrossAxisOverflow = + checkCrossAxis === 'alignment' + ? initialSideAxis !== getSideAxis(nextPlacement) + : false + if ( + !ignoreCrossAxisOverflow || + overflowsData.every((d) => + getSideAxis(d.placement) === initialSideAxis ? d.overflows[0] > 0 : true + ) + ) { + return { + data: { index: nextIndex, overflows: overflowsData }, + reset: { placement: nextPlacement } + } + } + } + + // Exhausted the list — pick the best fit on the main axis, then cross. + let resetPlacement = overflowsData + .filter((d) => d.overflows[0] <= 0) + .sort((a, b) => a.overflows[1] - b.overflows[1])[0]?.placement + + if (!resetPlacement) { + switch (fallbackStrategy) { + case 'bestFit': { + const best = overflowsData + .filter((d) => { + if (hasFallbackAxisSideDirection) { + const currentSideAxis = getSideAxis(d.placement) + return currentSideAxis === initialSideAxis || currentSideAxis === 'y' + } + return true + }) + .map( + (d) => + [ + d.placement, + d.overflows.filter((o) => o > 0).reduce((acc, o) => acc + o, 0) + ] as const + ) + .sort((a, b) => a[1] - b[1])[0]?.[0] + if (best) resetPlacement = best + break + } + case 'initialPlacement': + resetPlacement = initialPlacement + break + } + } + + if (placement !== resetPlacement) { + return { reset: { placement: resetPlacement } } + } + } + + return {} + } + } +} diff --git a/src/uix/soma/layers/floating/engine/middleware/hide.ts b/src/uix/soma/layers/floating/engine/middleware/hide.ts new file mode 100644 index 000000000..6c4b6c82f --- /dev/null +++ b/src/uix/soma/layers/floating/engine/middleware/hide.ts @@ -0,0 +1,58 @@ +// hide — detects when the floating element should be hidden because the +// reference left the shared clipping context (`referenceHidden`) or the floating +// element escaped its boundary (`escaped`). PURE. Reimplemented from +// floating-ui's `hide` (spec). The soma layer uses `referenceHidden` only. + +import { detectOverflow, type DetectOverflowOptions } from '../overflow' +import type { Dimensions, Middleware, SideObject } from '../types' + +export type HideOptions = DetectOverflowOptions & { + strategy?: 'referenceHidden' | 'escaped' +} + +const SIDES = ['top', 'right', 'bottom', 'left'] as const + +function getSideOffsets(overflow: SideObject, rect: Dimensions): SideObject { + return { + top: overflow.top - rect.height, + right: overflow.right - rect.width, + bottom: overflow.bottom - rect.height, + left: overflow.left - rect.width + } +} + +function isAnySideFullyClipped(overflow: SideObject): boolean { + return SIDES.some((side) => overflow[side] >= 0) +} + +export function hide(options: HideOptions = {}): Middleware { + const { strategy = 'referenceHidden', ...detectOptions } = options + return { + name: 'hide', + options, + fn(state) { + const { rects } = state + switch (strategy) { + case 'referenceHidden': { + const overflow = detectOverflow(state, { ...detectOptions, elementContext: 'reference' }) + const offsets = getSideOffsets(overflow, rects.reference) + return { + data: { + referenceHiddenOffsets: offsets, + referenceHidden: isAnySideFullyClipped(offsets) + } + } + } + case 'escaped': { + const overflow = detectOverflow(state, { ...detectOptions, altBoundary: true }) + const offsets = getSideOffsets(overflow, rects.floating) + return { + data: { escapedOffsets: offsets, escaped: isAnySideFullyClipped(offsets) } + } + } + default: + return {} + } + } + } +} diff --git a/src/uix/soma/layers/floating/engine/middleware/index.ts b/src/uix/soma/layers/floating/engine/middleware/index.ts new file mode 100644 index 000000000..877677976 --- /dev/null +++ b/src/uix/soma/layers/floating/engine/middleware/index.ts @@ -0,0 +1,11 @@ +// The middleware factories — each returns a pure `Middleware` matching +// floating-ui's call signatures, so `floating.svelte`'s array works with either +// implementation under the `USE_OWN_ENGINE` flag. + +export { offset, type OffsetOptions } from './offset' +export { shift, type ShiftOptions } from './shift' +export { flip, type FlipOptions } from './flip' +export { arrow, type ArrowOptions } from './arrow' +export { size, type SizeOptions, type SizeApplyArgs } from './size' +export { hide, type HideOptions } from './hide' +export { limitShift, type LimitShiftOptions, type ShiftLimiter } from './limit-shift' diff --git a/src/uix/soma/layers/floating/engine/middleware/limit-shift.ts b/src/uix/soma/layers/floating/engine/middleware/limit-shift.ts new file mode 100644 index 000000000..2f5f3863a --- /dev/null +++ b/src/uix/soma/layers/floating/engine/middleware/limit-shift.ts @@ -0,0 +1,69 @@ +// limitShift — the shift limiter used for `sticky: 'partial'`: stops the shift +// once the reference edge would pass the floating element. PURE. Reimplemented +// from floating-ui's `limitShift` (spec). Returns a limiter (no `name`), passed +// to `shift({ limiter })`. + +import { getOppositeAxis, getSide, getSideAxis } from '../geometry' +import type { Coords, MiddlewareState } from '../types' + +export type LimitShiftOptions = { + offset?: number | { mainAxis?: number; crossAxis?: number } + mainAxis?: boolean + crossAxis?: boolean +} + +export type ShiftLimiter = { + readonly options?: unknown + readonly fn: (state: MiddlewareState) => Coords +} + +const ORIGIN_SIDES = new Set(['left', 'top']) + +export function limitShift(options: LimitShiftOptions = {}): ShiftLimiter { + return { + options, + fn(state) { + const { x, y, placement, rects, middlewareData } = state + const { offset = 0, mainAxis: checkMainAxis = true, crossAxis: checkCrossAxis = true } = + options + const coords: Coords = { x, y } + const crossAxis = getSideAxis(placement) + const mainAxis = getOppositeAxis(crossAxis) + let mainAxisCoord = coords[mainAxis] + let crossAxisCoord = coords[crossAxis] + + const computedOffset = + typeof offset === 'number' + ? { mainAxis: offset, crossAxis: 0 } + : { mainAxis: 0, crossAxis: 0, ...offset } + + if (checkMainAxis) { + const len = mainAxis === 'y' ? 'height' : 'width' + const limitMin = rects.reference[mainAxis] - rects.floating[len] + computedOffset.mainAxis + const limitMax = rects.reference[mainAxis] + rects.reference[len] - computedOffset.mainAxis + if (mainAxisCoord < limitMin) mainAxisCoord = limitMin + else if (mainAxisCoord > limitMax) mainAxisCoord = limitMax + } + + if (checkCrossAxis) { + const len = mainAxis === 'y' ? 'width' : 'height' + const isOriginSide = ORIGIN_SIDES.has(getSide(placement)) + const offsetData = middlewareData.offset as Coords | undefined + const limitMin = + rects.reference[crossAxis] - + rects.floating[len] + + (isOriginSide ? offsetData?.[crossAxis] || 0 : 0) + + (isOriginSide ? 0 : computedOffset.crossAxis) + const limitMax = + rects.reference[crossAxis] + + rects.reference[len] + + (isOriginSide ? 0 : offsetData?.[crossAxis] || 0) - + (isOriginSide ? computedOffset.crossAxis : 0) + if (crossAxisCoord < limitMin) crossAxisCoord = limitMin + else if (crossAxisCoord > limitMax) crossAxisCoord = limitMax + } + + return { [mainAxis]: mainAxisCoord, [crossAxis]: crossAxisCoord } as Coords + } + } +} diff --git a/src/uix/soma/layers/floating/engine/middleware/offset.ts b/src/uix/soma/layers/floating/engine/middleware/offset.ts new file mode 100644 index 000000000..bdc20bb93 --- /dev/null +++ b/src/uix/soma/layers/floating/engine/middleware/offset.ts @@ -0,0 +1,63 @@ +// offset — shifts the floating element along the main / cross / alignment axes. +// PURE. Reimplemented from floating-ui's `offset` (spec). + +import type { Placement } from '../../placement' +import { getAlignment, getSide, getSideAxis } from '../geometry' +import type { Coords, Middleware, MiddlewareState } from '../types' + +export type OffsetOptions = + | number + | { mainAxis?: number; crossAxis?: number; alignmentAxis?: number | null } + +const ORIGIN_SIDES = new Set(['left', 'top']) + +function convertValueToCoords(state: MiddlewareState, options: OffsetOptions): Coords { + const { placement, rtl } = state + const side = getSide(placement) + const alignment = getAlignment(placement) + const isVertical = getSideAxis(placement) === 'y' + const mainAxisMulti = ORIGIN_SIDES.has(side) ? -1 : 1 + const crossAxisMulti = rtl && isVertical ? -1 : 1 + + let mainAxis: number + let crossAxis: number + let alignmentAxis: number | null + if (typeof options === 'number') { + mainAxis = options + crossAxis = 0 + alignmentAxis = null + } else { + mainAxis = options.mainAxis || 0 + crossAxis = options.crossAxis || 0 + alignmentAxis = options.alignmentAxis ?? null + } + + if (alignment && typeof alignmentAxis === 'number') { + crossAxis = alignment === 'end' ? alignmentAxis * -1 : alignmentAxis + } + + return isVertical + ? { x: crossAxis * crossAxisMulti, y: mainAxis * mainAxisMulti } + : { x: mainAxis * mainAxisMulti, y: crossAxis * crossAxisMulti } +} + +export function offset(options: OffsetOptions = 0): Middleware { + return { + name: 'offset', + options, + fn(state) { + const { x, y, placement, middlewareData } = state + const diff = convertValueToCoords(state, options) + + // If the placement is unchanged and the arrow applied an alignment + // offset, leave the coords alone (offset already ran for this placement). + const offsetData = middlewareData.offset as { placement?: Placement } | undefined + const arrowData = middlewareData.arrow as { alignmentOffset?: number } | undefined + if (placement === offsetData?.placement && arrowData?.alignmentOffset) { + return {} + } + + return { x: x + diff.x, y: y + diff.y, data: { ...diff, placement } } + } + } +} diff --git a/src/uix/soma/layers/floating/engine/middleware/shift.ts b/src/uix/soma/layers/floating/engine/middleware/shift.ts new file mode 100644 index 000000000..f849d6bde --- /dev/null +++ b/src/uix/soma/layers/floating/engine/middleware/shift.ts @@ -0,0 +1,70 @@ +// shift — slides the floating element along an axis to keep it within the +// clipping boundary. PURE (detectOverflow reads pre-walked clipping). +// Reimplemented from floating-ui's `shift` (spec). + +import { getOppositeAxis, getSide, getSideAxis } from '../geometry' +import { detectOverflow, type DetectOverflowOptions } from '../overflow' +import type { Coords, Middleware } from '../types' +import type { ShiftLimiter } from './limit-shift' + +export type ShiftOptions = DetectOverflowOptions & { + mainAxis?: boolean + crossAxis?: boolean + limiter?: ShiftLimiter +} + +function clamp(start: number, value: number, end: number): number { + return Math.max(start, Math.min(value, end)) +} + +export function shift(options: ShiftOptions = {}): Middleware { + const { + mainAxis: checkMainAxis = true, + crossAxis: checkCrossAxis = false, + limiter, + ...detectOptions + } = options + return { + name: 'shift', + options, + fn(state) { + const { x, y, placement } = state + const coords: Coords = { x, y } + const overflow = detectOverflow(state, detectOptions) + const crossAxis = getSideAxis(getSide(placement)) + const mainAxis = getOppositeAxis(crossAxis) + let mainAxisCoord = coords[mainAxis] + let crossAxisCoord = coords[crossAxis] + + if (checkMainAxis) { + const minSide = mainAxis === 'y' ? 'top' : 'left' + const maxSide = mainAxis === 'y' ? 'bottom' : 'right' + const min = mainAxisCoord + overflow[minSide] + const max = mainAxisCoord - overflow[maxSide] + mainAxisCoord = clamp(min, mainAxisCoord, max) + } + if (checkCrossAxis) { + const minSide = crossAxis === 'y' ? 'top' : 'left' + const maxSide = crossAxis === 'y' ? 'bottom' : 'right' + const min = crossAxisCoord + overflow[minSide] + const max = crossAxisCoord - overflow[maxSide] + crossAxisCoord = clamp(min, crossAxisCoord, max) + } + + const nextCoords: Coords = { x: coords.x, y: coords.y } + nextCoords[mainAxis] = mainAxisCoord + nextCoords[crossAxis] = crossAxisCoord + const limited = limiter ? limiter.fn({ ...state, ...nextCoords }) : nextCoords + + return { + x: limited.x, + y: limited.y, + data: { + x: limited.x - x, + y: limited.y - y, + enabled: { [mainAxis]: checkMainAxis, [crossAxis]: checkCrossAxis } + } + } + } + } +} diff --git a/src/uix/soma/layers/floating/engine/middleware/size.ts b/src/uix/soma/layers/floating/engine/middleware/size.ts new file mode 100644 index 000000000..10b1d7c97 --- /dev/null +++ b/src/uix/soma/layers/floating/engine/middleware/size.ts @@ -0,0 +1,78 @@ +// size — computes the width/height available to the floating element within the +// clipping boundary and hands it to `apply` (the consumer writes CSS vars). +// PURE. Our engine never re-reads DOM mid-loop: `apply` doesn't resize the +// floating element synchronously in the soma layer, so the floating-ui +// `reset: { rects: true }` round-trip is unnecessary (a real resize re-triggers +// via the autoUpdate ResizeObserver). Reimplemented from floating-ui's `size`. + +import { getAlignment, getSide, getSideAxis } from '../geometry' +import { detectOverflow, type DetectOverflowOptions } from '../overflow' +import type { Middleware, MiddlewareState } from '../types' + +export type SizeApplyArgs = MiddlewareState & { + availableWidth: number + availableHeight: number +} + +export type SizeOptions = DetectOverflowOptions & { + apply?: (args: SizeApplyArgs) => void +} + +const { min, max } = Math + +export function size(options: SizeOptions = {}): Middleware { + const { apply = () => {}, ...detectOptions } = options + return { + name: 'size', + options, + fn(state) { + const { placement, rects, rtl, middlewareData } = state + const overflow = detectOverflow(state, detectOptions) + const side = getSide(placement) + const alignment = getAlignment(placement) + const isYAxis = getSideAxis(placement) === 'y' + const { width, height } = rects.floating + + let heightSide: 'top' | 'bottom' + let widthSide: 'left' | 'right' + if (side === 'top' || side === 'bottom') { + heightSide = side + widthSide = alignment === (rtl ? 'start' : 'end') ? 'left' : 'right' + } else { + widthSide = side as 'left' | 'right' + heightSide = alignment === 'end' ? 'top' : 'bottom' + } + + const maximumClippingHeight = height - overflow.top - overflow.bottom + const maximumClippingWidth = width - overflow.left - overflow.right + const overflowAvailableHeight = min(height - overflow[heightSide], maximumClippingHeight) + const overflowAvailableWidth = min(width - overflow[widthSide], maximumClippingWidth) + + const shiftData = middlewareData.shift as + | { enabled?: { x?: boolean; y?: boolean } } + | undefined + const noShift = !shiftData + let availableHeight = overflowAvailableHeight + let availableWidth = overflowAvailableWidth + if (shiftData?.enabled?.x) availableWidth = maximumClippingWidth + if (shiftData?.enabled?.y) availableHeight = maximumClippingHeight + + if (noShift && !alignment) { + const xMin = max(overflow.left, 0) + const xMax = max(overflow.right, 0) + const yMin = max(overflow.top, 0) + const yMax = max(overflow.bottom, 0) + if (isYAxis) { + availableWidth = + width - 2 * (xMin !== 0 || xMax !== 0 ? xMin + xMax : max(overflow.left, overflow.right)) + } else { + availableHeight = + height - 2 * (yMin !== 0 || yMax !== 0 ? yMin + yMax : max(overflow.top, overflow.bottom)) + } + } + + apply({ ...state, availableWidth, availableHeight }) + return {} + } + } +} diff --git a/src/uix/soma/layers/floating/engine/overflow.ts b/src/uix/soma/layers/floating/engine/overflow.ts new file mode 100644 index 000000000..5c93f60cf --- /dev/null +++ b/src/uix/soma/layers/floating/engine/overflow.ts @@ -0,0 +1,63 @@ +// detectOverflow — PURE. Compares the proposed element rect against the clipping +// region that the read phase already walked (`state.clipping`, viewport coords). +// No DOM read: the offsetParent→viewport conversion is folded into +// `state.viewportDelta` + `state.offsetScale` upfront. Reimplemented from +// floating-ui's `detectOverflow` (spec), our idioms. + +import { getPaddingObject } from './geometry' +import { rectToClientRect } from './rects' +import type { MiddlewareState, Padding, SideObject } from './types' + +export type DetectOverflowOptions = { + padding?: Padding + /** Which element's rect to test — `'floating'` at `state.x/y`, or the + * reference rect. @default 'floating' */ + elementContext?: 'floating' | 'reference' + /** Test against the OTHER element's clipping region (swaps the boundary + * source). @default false */ + altBoundary?: boolean +} + +/** + * Per-side overflow of the tested element past its clipping boundary, in CSS px: + * positive = overflowing by that many pixels, negative = pixels of slack before + * it would overflow, 0 = flush. + */ +export function detectOverflow( + state: MiddlewareState, + options: DetectOverflowOptions = {} +): SideObject { + const { padding = 0, elementContext = 'floating', altBoundary = false } = options + const { x, y, rects, clipping, viewportDelta, offsetScale } = state + + const paddingObject = getPaddingObject(padding) + const altContext = elementContext === 'floating' ? 'reference' : 'floating' + const context = altBoundary ? altContext : elementContext + + // `clipping.reference` is only read when needed; fall back to the floating + // clip (identical when the boundary is explicit — the soma layer's only case). + const clip = clipping[context] ?? clipping.floating + const clippingClientRect = rectToClientRect(clip) + + // The tested element's rect in offsetParent-relative coords… + const elementRect = + elementContext === 'floating' + ? { x, y, width: rects.floating.width, height: rects.floating.height } + : rects.reference + // …converted to viewport coords purely via the precomputed delta. + const elementClientRect = rectToClientRect({ + x: elementRect.x + viewportDelta.x, + y: elementRect.y + viewportDelta.y, + width: elementRect.width, + height: elementRect.height + }) + + return { + top: (clippingClientRect.top - elementClientRect.top + paddingObject.top) / offsetScale.y, + bottom: + (elementClientRect.bottom - clippingClientRect.bottom + paddingObject.bottom) / offsetScale.y, + left: (clippingClientRect.left - elementClientRect.left + paddingObject.left) / offsetScale.x, + right: + (elementClientRect.right - clippingClientRect.right + paddingObject.right) / offsetScale.x + } +} diff --git a/src/uix/soma/layers/floating/engine/types.ts b/src/uix/soma/layers/floating/engine/types.ts index 262f2baf2..a74157ea1 100644 --- a/src/uix/soma/layers/floating/engine/types.ts +++ b/src/uix/soma/layers/floating/engine/types.ts @@ -36,7 +36,8 @@ export type ClippingContext = { } /** State handed to each middleware. All layout already read into `rects` + - * `clipping` + `arrowDimensions`; middleware are pure (no DOM read). */ + * `clipping` + `arrowDimensions` + the conversion constants; middleware are + * PURE (no DOM read — that's why `dom` is absent here, unlike the config). */ export type MiddlewareState = { x: number y: number @@ -48,15 +49,31 @@ export type MiddlewareState = { readonly elements: FloatingElements readonly clipping: ClippingContext readonly arrowDimensions?: Dimensions - readonly dom: ActiveDom + /** + * Offset to convert an offsetParent-relative rect → viewport coordinates, + * precomputed once in the read phase: `convert({0,0,0,0}) → {x,y}`. The + * conversion is `viewport = offsetParentRelative + viewportDelta` (the + * scroll/offset terms are independent of the rect being converted), so + * `detectOverflow` converts purely. See `compute.ts`. + */ + readonly viewportDelta: Coords + /** The offsetParent's CSS scale (`{1,1}` when it isn't a scaled element). + * `detectOverflow` divides its result by this — matching floating-ui. */ + readonly offsetScale: Coords + /** Floating element's resolved `direction === 'rtl'` (read once upfront). */ + readonly rtl: boolean } export type MiddlewareReturn = { x?: number y?: number data?: Record - /** Restart the chain (flip): recompute base coords for the new placement. */ - reset?: { placement?: Placement } + /** + * Restart the chain. `{ placement }` recomputes base coords for the new + * placement (flip); a bare `true` restarts WITHOUT recomputing coords — + * the arrow uses it after applying an alignment offset. + */ + reset?: boolean | { placement?: Placement } } export type Middleware = { diff --git a/src/uix/soma/layers/floating/floating.svelte.ts b/src/uix/soma/layers/floating/floating.svelte.ts index c26c823e2..1d46394ba 100644 --- a/src/uix/soma/layers/floating/floating.svelte.ts +++ b/src/uix/soma/layers/floating/floating.svelte.ts @@ -1,14 +1,25 @@ import { - type Middleware, - arrow, - autoUpdate, - flip, - hide, - limitShift, - offset, - shift, - size + arrow as fuiArrow, + autoUpdate as fuiAutoUpdate, + flip as fuiFlip, + hide as fuiHide, + limitShift as fuiLimitShift, + offset as fuiOffset, + shift as fuiShift, + size as fuiSize } from '@floating-ui/dom'; +import { + arrow as ownArrow, + flip as ownFlip, + hide as ownHide, + limitShift as ownLimitShift, + offset as ownOffset, + shift as ownShift, + size as ownSize +} from './engine/middleware'; +import { autoUpdate as ownAutoUpdate } from './engine/auto-update'; +import { USE_OWN_ENGINE } from './engine/flag'; +import type { Middleware } from './types'; import { attachRef, type RefAttachment } from '$libs/reactive'; import { cssToStyleObj, styleToString } from '../../css'; import type { ActiveDom } from '$adom'; @@ -24,6 +35,21 @@ import { useFloating } from './use-floating.svelte'; import type { Measurable, UseFloatingReturn } from './types'; import { OPPOSITE_SIDE, type Align, type Boundary, type Placement, type Side } from './placement'; +// A/B middleware factory set — own engine or @floating-ui, selected once via the +// flag. Typed loosely so the single `middleware` array expression below serves +// both implementations (the option shapes are structurally equivalent for our +// usage). Removed in P4 together with the @floating-ui imports + the flag. +type MiddlewareFactory = (options?: unknown) => Middleware; +type LimiterFactory = (options?: unknown) => unknown; + +const offset = (USE_OWN_ENGINE ? ownOffset : fuiOffset) as unknown as MiddlewareFactory; +const shift = (USE_OWN_ENGINE ? ownShift : fuiShift) as unknown as MiddlewareFactory; +const flip = (USE_OWN_ENGINE ? ownFlip : fuiFlip) as unknown as MiddlewareFactory; +const size = (USE_OWN_ENGINE ? ownSize : fuiSize) as unknown as MiddlewareFactory; +const arrow = (USE_OWN_ENGINE ? ownArrow : fuiArrow) as unknown as MiddlewareFactory; +const hide = (USE_OWN_ENGINE ? ownHide : fuiHide) as unknown as MiddlewareFactory; +const limitShift = (USE_OWN_ENGINE ? ownLimitShift : fuiLimitShift) as unknown as LimiterFactory; + // ── FloatingProvider ───────────────────────────────────────────────────────── export interface FloatingProviderOpts { @@ -156,7 +182,15 @@ export class FloatingContent { this.opts.avoidCollisions.current && flip({ ...this.detectOverflowOptions }), size({ ...this.detectOverflowOptions, - apply: ({ rects, availableWidth, availableHeight }) => { + apply: ({ + rects, + availableWidth, + availableHeight + }: { + rects: { reference: { width: number; height: number } }; + availableWidth: number; + availableHeight: number; + }) => { const { width: anchorWidth, height: anchorHeight } = rects.reference; this.availableWidth = availableWidth; this.availableHeight = availableHeight; @@ -289,11 +323,15 @@ export class FloatingContent { placement: () => this.desiredPlacement, middleware: () => this.middleware, reference: this.provider.anchorNode, - whileElementsMounted: (...args) => { - const cleanup = autoUpdate(...args, { - animationFrame: this.updatePositionStrategy?.current === 'always' - }); - return cleanup; + dom: this.provider.opts.dom, + whileElementsMounted: (reference, floating, update) => { + const animationFrame = this.updatePositionStrategy?.current === 'always'; + return USE_OWN_ENGINE + ? ownAutoUpdate(reference, floating, update, { + animationFrame, + dom: this.provider.opts.dom + }) + : fuiAutoUpdate(reference, floating, update, { animationFrame }); }, open: () => this.opts.enabled.current, sideOffset: () => this.opts.sideOffset.current, diff --git a/src/uix/soma/layers/floating/types.ts b/src/uix/soma/layers/floating/types.ts index 76ba4985d..716a16987 100644 --- a/src/uix/soma/layers/floating/types.ts +++ b/src/uix/soma/layers/floating/types.ts @@ -1,4 +1,5 @@ -import type { Middleware } from '@floating-ui/dom'; +import type { Middleware } from './engine/types'; +import type { ActiveDom } from '$adom'; import type { Active, State @@ -9,6 +10,10 @@ import type { Align, Boundary, Placement, Side, Strategy } from './placement'; // ─── Shared ─────────────────────────────────────────────────────────────────── +// The middleware contract now lives in `engine/types` (de-vendored from +// `@floating-ui/dom`). Re-exported here so `./types` stays the public surface. +export type { Middleware } from './engine/types'; + export type Measurable = { getBoundingClientRect: () => DOMRect; }; @@ -56,6 +61,9 @@ export type UseFloatingOptions = { sideOffset?: () => number; /** Align offset — used for bad-coordinate detection during transitions. @default 0 */ alignOffset?: () => number; + /** DOM runtime — threaded to the in-house engine's read phase / autoUpdate + * when `USE_OWN_ENGINE` is on. Absent → the `@floating-ui` path. */ + dom?: ActiveDom; }; // ─── useFloating Return ────────────────────────────────────────────────────── diff --git a/src/uix/soma/layers/floating/use-floating.svelte.ts b/src/uix/soma/layers/floating/use-floating.svelte.ts index 48a146739..f23c88fff 100644 --- a/src/uix/soma/layers/floating/use-floating.svelte.ts +++ b/src/uix/soma/layers/floating/use-floating.svelte.ts @@ -1,5 +1,5 @@ -import { computePosition } from '@floating-ui/dom'; -import type { Middleware } from '@floating-ui/dom'; +import { computePosition as fuiComputePosition } from '@floating-ui/dom'; +import type { Middleware as FuiMiddleware } from '@floating-ui/dom'; import { readableActive, state @@ -7,6 +7,8 @@ import { import { getDPR, roundByDPR, isReferenceHidden } from './utils'; import type { MiddlewareData, UseFloatingOptions, UseFloatingReturn } from './types'; import type { Placement, Strategy } from './placement'; +import { computePosition as ownComputePosition } from './engine/compute'; +import { USE_OWN_ENGINE } from './engine/flag'; export function useFloating(options: UseFloatingOptions): UseFloatingReturn { const whileElementsMountedOption = options.whileElementsMounted; @@ -18,6 +20,7 @@ export function useFloating(options: UseFloatingOptions): UseFloatingReturn { const sideOffsetOption = $derived(options.sideOffset?.() ?? 0); const alignOffsetOption = $derived(options.alignOffset?.() ?? 0); const reference = options.reference; + const domOption = options.dom; let x = $state(0); let y = $state(0); @@ -57,11 +60,25 @@ export function useFloating(options: UseFloatingOptions): UseFloatingReturn { function update() { if (reference.current === null || floating.current === null) return; - computePosition(reference.current, floating.current, { - middleware: middlewareOption, - placement: placementOption, - strategy: strategyOption - }).then((position) => { + // A/B: own engine when the flag is on AND a `dom` runtime was threaded + // through; otherwise the `@floating-ui` path. The `as unknown as` cast + // crosses the two structurally-similar middleware shapes — removed in P4 + // when `@floating-ui` (and the flag) go away. + const positioned = + USE_OWN_ENGINE && domOption + ? ownComputePosition(reference.current, floating.current, { + middleware: middlewareOption, + placement: placementOption, + strategy: strategyOption, + dom: domOption + }) + : fuiComputePosition(reference.current, floating.current, { + middleware: middlewareOption as unknown as FuiMiddleware[], + placement: placementOption, + strategy: strategyOption + }); + + positioned.then((position) => { const referenceNode = reference.current; const referenceHidden = isReferenceHidden(referenceNode); if (referenceHidden) {