diff --git a/src/lib/box.svelte.ts b/src/lib/box.svelte.ts deleted file mode 100644 index c36d314a..00000000 --- a/src/lib/box.svelte.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Getter } from './types.js'; - -export type ReadableBox = { - readonly value: T; -}; - -export type WritableBox = { - value: T; -}; - -class StateBox { - value = $state(); - - constructor(initialValue: T) { - this.value = initialValue; - } -} - -class DerivedBox { - #getter: Getter; - - constructor(getter: Getter) { - this.#getter = getter; - } - - readonly value = $derived.by(() => this.#getter()); -} - -class ReadonlyBox { - #box: ReadableBox; - - constructor(box: ReadableBox) { - this.#box = box; - } - - get value() { - return this.#box.value; - } -} - -/** - * Creates a writable box. - * - * @returns A box with a `value` property which can be set to a new value. - * Useful to pass state to other functions. - */ -export function box(): WritableBox; - -/** - * Creates a writable box with an initial value. - * - * @param initialValue The initial value of the box. - * @returns A box with a `value` property which can be set to a new value. - * Useful to pass state to other functions. - */ -export function box(initialValue: T): WritableBox; - -export function box(initialValue?: T): WritableBox { - return new StateBox(initialValue); -} - -box.derived = function (getter: Getter): ReadableBox { - return new DerivedBox(getter); -}; - -box.readonly = function (box: ReadableBox): ReadableBox { - return new ReadonlyBox(box); -}; diff --git a/src/lib/hooks/use-floating.svelte.ts b/src/lib/hooks/use-floating.svelte.ts index 8207c799..aa143630 100644 --- a/src/lib/hooks/use-floating.svelte.ts +++ b/src/lib/hooks/use-floating.svelte.ts @@ -1,95 +1,214 @@ -import { box } from '$lib/box.svelte.js'; -import type { UseFloatingOptions, UseFloatingReturn } from '$lib/types.js'; +import type { FloatingElements, OpenChangeReason, UseFloatingOptions } from '$lib/types.js'; import { getDPR, noop, roundByDPR, styleObjectToString } from '$lib/utils.js'; -import type { MiddlewareData, ReferenceElement } from '@floating-ui/dom'; -import { computePosition } from '@floating-ui/dom'; +import { + computePosition, + type Strategy, + type Placement, + type MiddlewareData +} from '@floating-ui/dom'; -/** - * Hook for managing floating elements. - * Aims to keep as much parity with `@floating-ui/react` as possible. - * For now see: https://floating-ui.com/docs/useFloating for API documentation. - */ -export function useFloating( - options: UseFloatingOptions = {} -): UseFloatingReturn { - const openOption = box.derived(() => options.open ?? true); - const onOpenChangeOption = options.onOpenChange ?? noop; - const placementOption = box.derived(() => options.placement ?? 'bottom'); - const strategyOption = box.derived(() => options.strategy ?? 'absolute'); - const middlewareOption = box.derived(() => options.middleware); - const transformOption = box.derived(() => options.transform ?? true); - const referenceElement = box.derived(() => options.elements?.reference); - const floatingElement = box.derived(() => options.elements?.floating); - const whileElementsMountedOption = options.whileElementsMounted; - - const x = box(0); - const y = box(0); - const strategy = box(strategyOption.value); - const placement = box(placementOption.value); - const middlewareData = box({}); - const isPositioned = box(false); - const floatingStyles = box.derived(() => { +class FloatingState { + readonly #options: UseFloatingOptions; + + constructor(options: UseFloatingOptions) { + this.#options = options; + this.placement = this.placementOption; + this.strategy = this.strategyOption; + } + + open = $derived.by(() => this.#options.open ?? true); + onOpenChange = $derived.by(() => this.#options.onOpenChange ?? noop); + placementOption = $derived.by(() => this.#options.placement ?? 'bottom'); + strategyOption = $derived.by(() => this.#options.strategy ?? 'absolute'); + middleware = $derived.by(() => this.#options.middleware); + transform = $derived.by(() => this.#options.transform ?? true); + elements = $derived.by(() => this.#options.elements ?? {}); + whileElementsMounted = $derived.by(() => this.#options.whileElementsMounted); + + x = $state(0); + y = $state(0); + placement: Placement = $state('bottom'); + strategy: Strategy = $state('absolute'); + middlewareData: MiddlewareData = $state.frozen({}); + isPositioned = $state(false); + floatingStyles = $derived.by(() => { const initialStyles = { - position: strategy.value, + position: this.strategy, left: '0', top: '0' }; - if (!floatingElement.value) { + const { floating } = this.elements; + if (floating == null) { return styleObjectToString(initialStyles); } - const xVal = roundByDPR(floatingElement.value, x.value); - const yVal = roundByDPR(floatingElement.value, y.value); + const xVal = roundByDPR(floating, this.x); + const yVal = roundByDPR(floating, this.y); - if (transformOption.value) { + if (this.transform) { return styleObjectToString({ ...initialStyles, transform: `translate(${xVal}px, ${yVal}px)`, - ...(getDPR(floatingElement.value) >= 1.5 && { willChange: 'transform' }) + ...(getDPR(floating) >= 1.5 && { willChange: 'transform' }) }); } return styleObjectToString({ - position: strategy.value, + position: this.strategyOption, left: `${xVal}px`, top: `${yVal}px` }); }); +} + +export class FloatingContext { + readonly #state: FloatingState; + + constructor(state: FloatingState) { + this.#state = state; + } + + /** + * Represents the open/close state of the floating element. + * @default true + */ + get open(): boolean { + return this.#state.open; + } + + /** + * Event handler that can be invoked whenever the open state changes. + */ + get onOpenChange(): (open: boolean, event?: Event, reason?: OpenChangeReason) => void { + return this.#state.onOpenChange; + } + + /** + * The reference and floating elements. + */ + get elements(): FloatingElements { + return this.#state.elements; + } +} + +export class UseFloatingReturn { + readonly #state: FloatingState; + readonly #context: FloatingContext; + readonly #update: () => void; + + constructor(state: FloatingState, update: () => void) { + this.#state = state; + this.#context = new FloatingContext(state); + this.#update = update; + } + + /** + * The x-coord of the floating element. + */ + get x(): number { + return this.#state.x; + } + + /** + * The y-coord of the floating element. + */ + get y(): number { + return this.#state.y; + } + + /** + * The stateful placement, which can be different from the initial `placement` passed as options. + */ + get placement(): Placement { + return this.#state.placement; + } + + /** + * The type of CSS position property to use. + */ + get strategy(): Strategy { + return this.#state.strategy; + } + + /** + * Additional data from middleware. + */ + get middlewareData(): MiddlewareData { + return this.#state.middlewareData; + } + + /** + * The boolean that let you know if the floating element has been positioned. + */ + get isPositioned(): boolean { + return this.#state.isPositioned; + } + + /** + * CSS styles to apply to the floating element to position it. + */ + get floatingStyles(): string { + return this.#state.floatingStyles; + } + + /** + * The function to update floating position manually. + */ + get update(): () => void { + return this.#update; + } + + /** + * Context object containing internal logic to alter the behavior of the floating element. + * Commonly used to inject into others hooks. + */ + get context(): FloatingContext { + return this.#context; + } +} + +/** + * Hook for managing floating elements. + */ +export function useFloating(options: UseFloatingOptions = {}): UseFloatingReturn { + const state = new FloatingState(options); function update() { - if (referenceElement.value == null || floatingElement.value == null) { + const { reference, floating } = state.elements; + if (reference == null || floating == null) { return; } - computePosition(referenceElement.value, floatingElement.value, { - middleware: middlewareOption.value, - placement: placementOption.value, - strategy: strategyOption.value + computePosition(reference, floating, { + middleware: state.middleware, + placement: state.placementOption, + strategy: state.strategyOption }).then((position) => { - x.value = position.x; - y.value = position.y; - strategy.value = position.strategy; - placement.value = position.placement; - middlewareData.value = position.middlewareData; - isPositioned.value = true; + state.x = position.x; + state.y = position.y; + state.strategy = position.strategy; + state.placement = position.placement; + state.middlewareData = position.middlewareData; + state.isPositioned = true; }); } function attach() { - if (whileElementsMountedOption === undefined) { + if (state.whileElementsMounted === undefined) { update(); return; } - if (referenceElement.value != null && floatingElement.value != null) { - return whileElementsMountedOption(referenceElement.value, floatingElement.value, update); + const { floating, reference } = state.elements; + if (reference != null && floating != null) { + return state.whileElementsMounted(reference, floating, update); } } function reset() { - if (!openOption.value) { - isPositioned.value = false; + if (!state.open) { + state.isPositioned = false; } } @@ -97,22 +216,5 @@ export function useFloating( $effect.pre(attach); $effect.pre(reset); - return { - x: box.readonly(x), - y: box.readonly(y), - strategy: box.readonly(strategy), - placement: box.readonly(placement), - middlewareData: box.readonly(middlewareData), - isPositioned: box.readonly(isPositioned), - floatingStyles, - update, - context: { - open: openOption, - onOpenChange: onOpenChangeOption, - elements: { - reference: box.readonly(referenceElement), - floating: box.readonly(floatingElement) - } - } - }; + return new UseFloatingReturn(state, update); } diff --git a/src/lib/hooks/use-floating.test.svelte.ts b/src/lib/hooks/use-floating.test.svelte.ts index 87439447..4eaba8ff 100644 --- a/src/lib/hooks/use-floating.test.svelte.ts +++ b/src/lib/hooks/use-floating.test.svelte.ts @@ -34,7 +34,7 @@ describe('useFloating', () => { it_in_effect('updates floating coordinates on middleware change', async () => { const middleware: Middleware[] = $state([]); - const { x, y } = useFloating({ + const floating = useFloating({ ...test_config(), get middleware() { return middleware; @@ -42,21 +42,21 @@ describe('useFloating', () => { }); await vi.waitFor(() => { - expect(x.value).toBe(0); - expect(y.value).toBe(0); + expect(floating.x).toBe(0); + expect(floating.y).toBe(0); }); middleware.push(offset(5)); await vi.waitFor(() => { - expect(x.value).toBe(0); - expect(y.value).toBe(5); + expect(floating.x).toBe(0); + expect(floating.y).toBe(5); }); }); it_in_effect('updates floating coordinates on placement change', async () => { let placement: Placement = $state('bottom'); - const { x, y } = useFloating({ + const floating = useFloating({ ...test_config(), middleware: [offset(5)], get placement() { @@ -65,21 +65,21 @@ describe('useFloating', () => { }); await vi.waitFor(() => { - expect(x.value).toBe(0); - expect(y.value).toBe(5); + expect(floating.x).toBe(0); + expect(floating.y).toBe(5); }); placement = 'top'; await vi.waitFor(() => { - expect(x.value).toBe(0); - expect(y.value).toBe(-5); + expect(floating.x).toBe(0); + expect(floating.y).toBe(-5); }); }); it_in_effect('updates `floatingStyles` on strategy change', async () => { let strategy: Strategy = $state('absolute'); - const { floatingStyles } = useFloating({ + const floating = useFloating({ ...test_config(), get strategy() { return strategy; @@ -87,51 +87,51 @@ describe('useFloating', () => { }); await vi.waitFor(() => { - expect(floatingStyles.value).toContain('position: absolute'); + expect(floating.floatingStyles).toContain('position: absolute'); }); strategy = 'fixed'; await vi.waitFor(() => { - expect(floatingStyles.value).toContain('position: fixed'); + expect(floating.floatingStyles).toContain('position: fixed'); }); }); it_in_effect('updates `isPositioned` when position is computed', async () => { - const { x, y, isPositioned } = useFloating({ + const floating = useFloating({ ...test_config(), middleware: [offset(5)] }); - expect(x.value).toBe(0); - expect(y.value).toBe(0); - expect(isPositioned.value).toBe(false); + expect(floating.x).toBe(0); + expect(floating.y).toBe(0); + expect(floating.isPositioned).toBe(false); await vi.waitFor(() => { - expect(x.value).toBe(0); - expect(y.value).toBe(5); - expect(isPositioned.value).toBe(true); + expect(floating.x).toBe(0); + expect(floating.y).toBe(5); + expect(floating.isPositioned).toBe(true); }); }); it_in_effect('updates `isPositioned` to `false` when `open` is set to `false`', async () => { let open = $state(true); - const { isPositioned } = useFloating({ + const floating = useFloating({ ...test_config(), get open() { return open; } }); - expect(isPositioned.value).toBe(false); + expect(floating.isPositioned).toBe(false); await vi.waitFor(() => { - expect(isPositioned.value).toBe(true); + expect(floating.isPositioned).toBe(true); }); open = false; await vi.waitFor(() => { - expect(isPositioned.value).toBe(false); + expect(floating.isPositioned).toBe(false); }); }); it_in_effect( @@ -139,7 +139,7 @@ describe('useFloating', () => { async () => { let placement: Placement | undefined = $state('top'); - const { x, y } = useFloating({ + const floating = useFloating({ ...test_config(), middleware: [offset(5)], get placement() { @@ -148,15 +148,15 @@ describe('useFloating', () => { }); await vi.waitFor(() => { - expect(x.value).toBe(0); - expect(y.value).toBe(-5); + expect(floating.x).toBe(0); + expect(floating.y).toBe(-5); }); placement = undefined; await vi.waitFor(() => { - expect(x.value).toBe(0); - expect(y.value).toBe(5); + expect(floating.x).toBe(0); + expect(floating.y).toBe(5); }); } ); @@ -165,7 +165,7 @@ describe('useFloating', () => { async () => { let strategy: Strategy | undefined = $state('fixed'); - const { floatingStyles } = useFloating({ + const floating = useFloating({ ...test_config(), get strategy() { return strategy; @@ -173,13 +173,13 @@ describe('useFloating', () => { }); await vi.waitFor(() => { - expect(floatingStyles.value).toContain('position: fixed'); + expect(floating.floatingStyles).toContain('position: fixed'); }); strategy = undefined; await vi.waitFor(() => { - expect(floatingStyles.value).toContain('position: absolute'); + expect(floating.floatingStyles).toContain('position: absolute'); }); } ); @@ -239,7 +239,7 @@ describe('useFloating', () => { expect(whileElementsMountedCleanup).toHaveBeenCalledTimes(1); }); it_in_effect('correctly assigns `middlewareData` from `middleware`', async () => { - const { middlewareData } = useFloating({ + const floating = useFloating({ ...test_config(), middleware: [ { @@ -250,7 +250,7 @@ describe('useFloating', () => { }); await vi.waitFor(() => { - expect(middlewareData.value).toEqual({ test: { content: 'Content' } }); + expect(floating.middlewareData).toEqual({ test: { content: 'Content' } }); }); }); }); diff --git a/src/lib/index.ts b/src/lib/index.ts index 25820456..7c243338 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,3 +1,3 @@ export * from '@floating-ui/dom'; -export { useFloating } from '$lib/hooks/use-floating.svelte.js'; -export { type UseFloatingOptions, type UseFloatingReturn } from '$lib/types.js'; +export { useFloating, type UseFloatingReturn } from '$lib/hooks/use-floating.svelte.js'; +export { type UseFloatingOptions } from '$lib/types.js'; diff --git a/src/lib/types.ts b/src/lib/types.ts index 089e8468..aa90bf8a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,81 +1,67 @@ -import type { ReadableBox } from '$lib/box.svelte.js'; import type { FloatingElement, Middleware, - MiddlewareData, Placement, ReferenceElement, Strategy } from '@floating-ui/dom'; -export type Getter = () => T; - export type Expand = T extends infer U ? { [K in keyof U]: U[K] } : never; -export interface UseFloatingOptions { +export interface UseFloatingOptions { /** * Represents the open/close state of the floating element. * @default true */ - open?: boolean; + readonly open?: boolean; /** * Event handler that can be invoked whenever the open state changes. */ - onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; + readonly onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; /** * Where to place the floating element relative to its reference element. * @default 'bottom' */ - placement?: Placement; + readonly placement?: Placement; /** * The type of CSS position property to use. * @default 'absolute' */ - strategy?: Strategy; + readonly strategy?: Strategy; /** * These are plain objects that modify the positioning coordinates in some fashion, or provide useful data for the consumer to use. * @default undefined */ - middleware?: Array; + readonly middleware?: Array; /** * Whether to use `transform` instead of `top` and `left` styles to * position the floating element (`floatingStyles`). * @default true */ - transform?: boolean; + readonly transform?: boolean; /** * The reference and floating elements. */ - elements?: { - /** - * The reference element. - */ - reference?: T | null; - - /** - * The floating element which is anchored to the reference element. - */ - floating?: FloatingElement | null; - }; + readonly elements?: FloatingElements; /** * Callback to handle mounting/unmounting of the elements. * @default undefined */ - whileElementsMounted?: ( - reference: T, + readonly whileElementsMounted?: ( + reference: ReferenceElement, floating: FloatingElement, update: () => void ) => () => void; } -type OpenChangeReason = +export type OpenChangeReason = | 'outside-press' | 'escape-key' | 'ancestor-scroll' @@ -86,77 +72,14 @@ type OpenChangeReason = | 'list-navigation' | 'safe-polygon'; -export interface FloatingContext { - /** - * Represents the open/close state of the floating element. - */ - open: ReadableBox; - - /** - * Event handler that can be invoked whenever the open state changes. - */ - onOpenChange: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; - +export type FloatingElements = { /** - * The reference and floating elements. - */ - elements: { - /** - * The reference element. - */ - reference: ReadableBox; - - /** - * The floating element which is anchored to the reference element. - */ - floating: ReadableBox; - }; -} - -export interface UseFloatingReturn { - /** - * The x-coord of the floating element. + * The reference element. */ - x: ReadableBox; + readonly reference?: ReferenceElement | null; /** - * The y-coord of the floating element. + * The floating element which is anchored to the reference element. */ - y: ReadableBox; - - /** - * The stateful placement, which can be different from the initial `placement` passed as options. - */ - placement: ReadableBox; - - /** - * The type of CSS position property to use. - */ - strategy: ReadableBox; - - /** - * Additional data from middleware. - */ - middlewareData: ReadableBox; - - /** - * The boolean that let you know if the floating element has been positioned. - */ - isPositioned: ReadableBox; - - /** - * CSS styles to apply to the floating element to position it. - */ - floatingStyles: ReadableBox; - - /** - * The function to update floating position manually. - */ - update: () => void; - - /** - * Context object containing internal logic to alter the behavior of the floating element. - * Commonly used to inject into others hooks. - */ - context: FloatingContext; -} + readonly floating?: FloatingElement | null; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 25d5f8b9..2c3a6dc2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,27 +1,27 @@ -
-
- -

Floating UI Svelte

-

A Svelte library for position floating elements and create interactions for them.

- -
-
+ + +

{floating.x}

+

{floating.y}

+ + +
Floating