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 <noreply@anthropic.com>
menubar-v4-safe
parent
9fecb80fac
commit
4438012198
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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<ComputePositionReturn> {
|
||||
const { placement = 'bottom', strategy = 'absolute', middleware = [], dom } = config
|
||||
const win = dom.getWindow(floating)
|
||||
|
||||
return new Promise<ComputePositionReturn>((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<string, unknown> | 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 }
|
||||
}
|
||||
@ -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<string, number> }) => unknown[]
|
||||
) {
|
||||
const arrowEl = {}
|
||||
const offsetParent = {}
|
||||
|
||||
const ownCaptured: Record<string, number> = {}
|
||||
const ownMw = build('own', { arrowEl, captured: ownCaptured }) as Middleware[]
|
||||
const own = runMiddleware(placement, 'absolute', ownMw, snapshotOf(scenario))
|
||||
|
||||
const fuiCaptured: Record<string, number> = {}
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
@ -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<string>(['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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<string>(['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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue