diff --git a/packages/core/src/primitives/create-floating.ts b/packages/core/src/primitives/create-floating.ts new file mode 100644 index 0000000..5cdfefd --- /dev/null +++ b/packages/core/src/primitives/create-floating.ts @@ -0,0 +1,95 @@ +import { + type Middleware, + type Placement, + type Strategy, + autoUpdate, + computePosition, +} from "@floating-ui/dom"; +import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js"; +import type { JSX } from "solid-js"; + +/** Options accepted by {@link createFloating}. */ +export interface CreateFloatingOptions { + /** Reference/anchor element. */ + anchor: Accessor; + /** The floating element to position. */ + floating: Accessor; + /** Desired placement. @default "bottom" */ + placement?: Accessor; + /** Floating UI middleware (flip, shift, offset, arrow, etc.). */ + middleware?: Accessor; + /** CSS positioning strategy. @default "absolute" */ + strategy?: Accessor; + /** Only compute position when true. @default () => true */ + open?: Accessor; +} + +/** Reactive floating position state returned by {@link createFloating}. */ +export interface FloatingState { + /** Computed x position in px. */ + x: Accessor; + /** Computed y position in px. */ + y: Accessor; + /** Actual placement after middleware (may differ from requested after flip). */ + placement: Accessor; + /** Ready-to-spread CSS: position, top, left. */ + style: Accessor; +} + +/** + * Thin reactive wrapper around `@floating-ui/dom`. + * + * Computes and auto-updates the position of a floating element + * relative to an anchor. Cleans up automatically when the reactive + * owner is disposed. + */ +export function createFloating(options: CreateFloatingOptions): FloatingState { + const [x, setX] = createSignal(0); + const [y, setY] = createSignal(0); + const [currentPlacement, setCurrentPlacement] = createSignal( + options.placement?.() ?? "bottom", + ); + + /** Resolve the CSS positioning strategy, defaulting to "absolute". */ + const getStrategy = () => options.strategy?.() ?? "absolute"; + /** Whether positioning should be active, defaulting to true. */ + const isOpen = () => options.open?.() ?? true; + + /** Runs computePosition and writes results into signals. */ + const update = async () => { + const anchor = options.anchor(); + const floating = options.floating(); + if (!anchor || !floating) return; + + const mw = options.middleware?.(); + const result = await computePosition(anchor, floating, { + placement: options.placement?.() ?? "bottom", + ...(mw ? { middleware: mw } : {}), + strategy: getStrategy(), + }); + + setX(result.x); + setY(result.y); + setCurrentPlacement(result.placement); + }; + + createEffect(() => { + const anchor = options.anchor(); + const floating = options.floating(); + + if (!anchor || !floating || !isOpen()) return; + + update(); + + const cleanup = autoUpdate(anchor, floating, update); + onCleanup(cleanup); + }); + + const style: Accessor = () => ({ + position: getStrategy(), + top: `${y()}px`, + left: `${x()}px`, + }); + + return { x, y, placement: currentPlacement, style }; +} diff --git a/packages/core/src/primitives/index.ts b/packages/core/src/primitives/index.ts index 74e489e..d20b880 100644 --- a/packages/core/src/primitives/index.ts +++ b/packages/core/src/primitives/index.ts @@ -2,4 +2,6 @@ export { createControllableSignal } from "./create-controllable-signal"; export type { CreateControllableSignalOptions } from "./create-controllable-signal"; export { createDisclosureState } from "./create-disclosure-state"; export type { CreateDisclosureStateOptions, DisclosureState } from "./create-disclosure-state"; +export { createFloating } from "./create-floating"; +export type { CreateFloatingOptions, FloatingState } from "./create-floating"; export { createRegisterId } from "./create-register-id"; diff --git a/packages/core/tests/primitives/create-floating.test.tsx b/packages/core/tests/primitives/create-floating.test.tsx new file mode 100644 index 0000000..b6b7548 --- /dev/null +++ b/packages/core/tests/primitives/create-floating.test.tsx @@ -0,0 +1,51 @@ +import { createSignal } from "solid-js"; +import { render } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { createFloating } from "../../src/primitives/create-floating"; +import type { CreateFloatingOptions } from "../../src/primitives/create-floating"; + +type PartialOpts = Partial>; + +function renderFloating(extra: PartialOpts = {}) { + let state: ReturnType | undefined; + + render(() => { + const [anchor, setAnchor] = createSignal(null); + const [floating, setFloating] = createSignal(null); + + state = createFloating({ anchor, floating, ...extra }); + + return ( +
+ +
Float
+
+ ); + }); + + return state!; +} + +describe("createFloating", () => { + it("returns reactive x, y, placement, and style", () => { + const state = renderFloating({ placement: () => "bottom" }); + + expect(typeof state.x()).toBe("number"); + expect(typeof state.y()).toBe("number"); + expect(state.placement()).toBe("bottom"); + expect(state.style()).toHaveProperty("position"); + }); + + it("does not compute when open is false", () => { + const state = renderFloating({ placement: () => "bottom", open: () => false }); + + expect(state.x()).toBe(0); + expect(state.y()).toBe(0); + }); + + it("style includes position strategy", () => { + const state = renderFloating({ strategy: () => "fixed" }); + + expect(state.style().position).toBe("fixed"); + }); +});