Floating positioning primitive

Reactive wrapper around @floating-ui/dom that auto-updates floating
element position relative to an anchor. Exports createFloating,
CreateFloatingOptions, and FloatingState from primitives index.
This commit is contained in:
Mats Bosson 2026-03-29 10:25:38 +07:00
parent 06eba6d551
commit c78a8832d9
3 changed files with 148 additions and 0 deletions

View File

@ -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<HTMLElement | null>;
/** The floating element to position. */
floating: Accessor<HTMLElement | null>;
/** Desired placement. @default "bottom" */
placement?: Accessor<Placement>;
/** Floating UI middleware (flip, shift, offset, arrow, etc.). */
middleware?: Accessor<Middleware[]>;
/** CSS positioning strategy. @default "absolute" */
strategy?: Accessor<Strategy>;
/** Only compute position when true. @default () => true */
open?: Accessor<boolean>;
}
/** Reactive floating position state returned by {@link createFloating}. */
export interface FloatingState {
/** Computed x position in px. */
x: Accessor<number>;
/** Computed y position in px. */
y: Accessor<number>;
/** Actual placement after middleware (may differ from requested after flip). */
placement: Accessor<Placement>;
/** Ready-to-spread CSS: position, top, left. */
style: Accessor<JSX.CSSProperties>;
}
/**
* 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<Placement>(
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<JSX.CSSProperties> = () => ({
position: getStrategy(),
top: `${y()}px`,
left: `${x()}px`,
});
return { x, y, placement: currentPlacement, style };
}

View File

@ -2,4 +2,6 @@ export { createControllableSignal } from "./create-controllable-signal";
export type { CreateControllableSignalOptions } from "./create-controllable-signal"; export type { CreateControllableSignalOptions } from "./create-controllable-signal";
export { createDisclosureState } from "./create-disclosure-state"; export { createDisclosureState } from "./create-disclosure-state";
export type { CreateDisclosureStateOptions, DisclosureState } 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"; export { createRegisterId } from "./create-register-id";

View File

@ -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<Pick<CreateFloatingOptions, "placement" | "strategy" | "open">>;
function renderFloating(extra: PartialOpts = {}) {
let state: ReturnType<typeof createFloating> | undefined;
render(() => {
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
const [floating, setFloating] = createSignal<HTMLElement | null>(null);
state = createFloating({ anchor, floating, ...extra });
return (
<div>
<button ref={setAnchor}>Anchor</button>
<div ref={setFloating}>Float</div>
</div>
);
});
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");
});
});