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 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";
|
||||||
|
|||||||
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