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:
parent
06eba6d551
commit
c78a8832d9
95
packages/core/src/primitives/create-floating.ts
Normal file
95
packages/core/src/primitives/create-floating.ts
Normal 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 };
|
||||
}
|
||||
@ -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";
|
||||
|
||||
51
packages/core/tests/primitives/create-floating.test.tsx
Normal file
51
packages/core/tests/primitives/create-floating.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user