feat(floating): P1 scaffold — engine types + pure placement geometry

First slice of the own positioning engine (replacing @floating-ui's math). Pure,
no-DOM foundation, not wired yet:
- engine/types.ts — internal positioning types (Rect/Coords/ElementRects/
  ClippingContext/MiddlewareState/MiddlewareReturn/Middleware/ComputePosition*).
  Read-phase model: one coalesced dom.measure reads rects + the full clipping
  ancestor-walk + arrow dims upfront; middleware then run purely.
- engine/geometry.ts — pure placement/coords math (getSide/getAlignment,
  getOppositePlacement, getOppositeAlignmentPlacement, computeCoordsFromPlacement)
  reimplemented from the floating-ui algorithm.

Compiles clean (0 new errors). The DOM-read core (rects + clipping ancestor-walk)
is the substantial next slice.

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

@ -0,0 +1,99 @@
// Pure placement / coordinate math. No DOM, no reads — all inputs are rects.
// Reimplemented from the floating-ui algorithm (spec), our idioms + types.
import type { Align, Placement, Side } from '../placement'
import { OPPOSITE_SIDE } from '../placement'
import type { Axis, Coords, ElementRects, Length, Padding, SideObject } from './types'
const OPPOSITE_ALIGN: Record<'start' | 'end', 'start' | 'end'> = { start: 'end', end: 'start' }
export function getSide(placement: Placement): Side {
return placement.split('-')[0] as Side
}
export function getAlignment(placement: Placement): Align | undefined {
return placement.split('-')[1] as Align | undefined
}
/** The axis the SIDE runs on: left/right → 'x', top/bottom → 'y'. */
export function getSideAxis(placement: Placement): Axis {
return ['left', 'right'].includes(getSide(placement)) ? 'x' : 'y'
}
/** The axis the ALIGNMENT slides on — perpendicular to the side axis. */
export function getAlignmentAxis(placement: Placement): Axis {
return getSideAxis(placement) === 'x' ? 'y' : 'x'
}
export function getAxisLength(axis: Axis): Length {
return axis === 'y' ? 'height' : 'width'
}
/** Flip the side: bottom ⇄ top, left ⇄ right (alignment preserved). */
export function getOppositePlacement(placement: Placement): Placement {
const align = getAlignment(placement)
const opposite = OPPOSITE_SIDE[getSide(placement)]
return (align ? `${opposite}-${align}` : opposite) as Placement
}
/** Flip the alignment: start ⇄ end (side preserved). Center (no suffix) unchanged. */
export function getOppositeAlignmentPlacement(placement: Placement): Placement {
const align = getAlignment(placement)
if (align !== 'start' && align !== 'end') return placement
return `${getSide(placement)}-${OPPOSITE_ALIGN[align]}` as Placement
}
export function getPaddingObject(padding: Padding): SideObject {
return typeof padding === 'number'
? { top: padding, right: padding, bottom: padding, left: padding }
: { top: 0, right: 0, bottom: 0, left: 0, ...padding }
}
/**
* The base coordinates for a placement — the floating element pinned to the
* reference per side + alignment, BEFORE any middleware (offset/shift/flip).
* Mirrors floating-ui's `computeCoordsFromPlacement`.
*/
export function computeCoordsFromPlacement(
rects: ElementRects,
placement: Placement,
rtl = false
): Coords {
const { reference, floating } = rects
const sideAxis = getSideAxis(placement)
const alignmentAxis = getAlignmentAxis(placement)
const alignLength = getAxisLength(alignmentAxis)
const side = getSide(placement)
const isVertical = sideAxis === 'y'
const commonX = reference.x + reference.width / 2 - floating.width / 2
const commonY = reference.y + reference.height / 2 - floating.height / 2
const commonAlign = reference[alignLength] / 2 - floating[alignLength] / 2
let coords: Coords
switch (side) {
case 'top':
coords = { x: commonX, y: reference.y - floating.height }
break
case 'bottom':
coords = { x: commonX, y: reference.y + reference.height }
break
case 'right':
coords = { x: reference.x + reference.width, y: commonY }
break
case 'left':
coords = { x: reference.x - floating.width, y: commonY }
break
default:
coords = { x: reference.x, y: reference.y }
}
const align = getAlignment(placement)
if (align === 'start') {
coords[alignmentAxis] -= commonAlign * (rtl && isVertical ? -1 : 1)
} else if (align === 'end') {
coords[alignmentAxis] += commonAlign * (rtl && isVertical ? -1 : 1)
}
return coords
}

@ -0,0 +1,81 @@
// Engine-internal positioning types. Reimplemented to our idioms (floating-ui as
// spec). The public `Middleware`/`MiddlewareData` in ../types are the consumer
// surface; these are the math types the engine threads internally.
import type { ActiveDom } from '$adom'
import type { Placement, Strategy } from '../placement'
import type { MiddlewareData, Measurable } from '../types'
export type Axis = 'x' | 'y'
export type Length = 'width' | 'height'
export type Coords = { x: number; y: number }
export type Dimensions = { width: number; height: number }
export type Rect = Coords & Dimensions
export type SideObject = { top: number; right: number; bottom: number; left: number }
export type Padding = number | Partial<SideObject>
export type ElementRects = { reference: Rect; floating: Rect }
export type FloatingElements = {
reference: Measurable | HTMLElement
floating: HTMLElement
}
/**
* The clipping region read ONCE upfront — the intersection of every overflow
* ancestor's clip + the viewport (the full ancestor-walk, option 2), expressed
* in viewport coordinates. `detectOverflow` then compares the floating rect (at
* the proposed coords) against this purely, with no further DOM read.
*/
export type ClippingContext = {
/** Floating element's clip: ∩ of its overflow ancestors' clips + viewport. */
readonly floating: Rect
/** Reference element's clip (only read when a `hide` middleware is active). */
readonly reference?: Rect
}
/** State handed to each middleware. All layout already read into `rects` +
* `clipping` + `arrowDimensions`; middleware are pure (no DOM read). */
export type MiddlewareState = {
x: number
y: number
readonly initialPlacement: Placement
placement: Placement
readonly strategy: Strategy
readonly middlewareData: MiddlewareData
readonly rects: ElementRects
readonly elements: FloatingElements
readonly clipping: ClippingContext
readonly arrowDimensions?: Dimensions
readonly dom: ActiveDom
}
export type MiddlewareReturn = {
x?: number
y?: number
data?: Record<string, unknown>
/** Restart the chain (flip): recompute base coords for the new placement. */
reset?: { placement?: Placement }
}
export type Middleware = {
readonly name: string
readonly options?: unknown
readonly fn: (state: MiddlewareState) => MiddlewareReturn
}
export type ComputePositionConfig = {
readonly placement?: Placement
readonly strategy?: Strategy
readonly middleware?: readonly Middleware[]
readonly dom: ActiveDom
}
export type ComputePositionReturn = {
readonly x: number
readonly y: number
readonly placement: Placement
readonly strategy: Strategy
readonly middlewareData: MiddlewareData
}
Loading…
Cancel
Save

Powered by TurnKey Linux.