From 19d448aab1a38f76a552579afb6d9d4e9467ffa3 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 7 Jan 2025 21:25:34 -0500 Subject: [PATCH 01/87] more --- packages/floating-ui-svelte/package.json | 7 +- .../src/components/floating-arrow.svelte | 304 +++++++++--------- .../floating-tree/floating-node.svelte | 29 ++ .../floating-tree/floating-tree.svelte | 37 +++ .../components/floating-tree/hooks.svelte.ts | 47 +++ .../src/hooks/use-click.svelte.ts | 4 +- .../src/hooks/use-dismiss.svelte.ts | 2 +- .../hooks/use-floating-root-context.svelte.ts | 72 +++++ .../src/hooks/use-focus.svelte.ts | 5 +- .../src/hooks/use-hover.svelte.ts | 2 +- ...ating.svelte.ts => use-position.svelte.ts} | 186 ++--------- .../src/hooks/use-role.svelte.ts | 2 +- packages/floating-ui-svelte/src/index.ts | 2 +- .../src/internal/context.ts | 76 +++++ ...able-element.ts => is-typeable-element.ts} | 0 .../floating-ui-svelte/src/internal/log.ts | 22 ++ packages/floating-ui-svelte/src/types.ts | 121 ++++++- .../test/components/floating-arrow.ts | 2 +- pnpm-lock.yaml | 3 + 19 files changed, 594 insertions(+), 329 deletions(-) create mode 100644 packages/floating-ui-svelte/src/components/floating-tree/floating-node.svelte create mode 100644 packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte create mode 100644 packages/floating-ui-svelte/src/components/floating-tree/hooks.svelte.ts create mode 100644 packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts rename packages/floating-ui-svelte/src/hooks/{use-floating.svelte.ts => use-position.svelte.ts} (56%) create mode 100644 packages/floating-ui-svelte/src/internal/context.ts rename packages/floating-ui-svelte/src/internal/{is-typable-element.ts => is-typeable-element.ts} (100%) create mode 100644 packages/floating-ui-svelte/src/internal/log.ts diff --git a/packages/floating-ui-svelte/package.json b/packages/floating-ui-svelte/package.json index f830df4a..f4ddfd25 100644 --- a/packages/floating-ui-svelte/package.json +++ b/packages/floating-ui-svelte/package.json @@ -9,7 +9,9 @@ "postinstall": "svelte-kit sync", "sync": "pnpm build" }, - "files": ["dist"], + "files": [ + "dist" + ], "sideEffects": false, "svelte": "./dist/index.js", "types": "./dist/index.d.ts", @@ -43,6 +45,7 @@ }, "dependencies": { "@floating-ui/dom": "^1.6.12", - "@floating-ui/utils": "^0.2.8" + "@floating-ui/utils": "^0.2.8", + "esm-env": "^1.2.1" } } diff --git a/packages/floating-ui-svelte/src/components/floating-arrow.svelte b/packages/floating-ui-svelte/src/components/floating-arrow.svelte index fda2b9b0..6fe9e830 100644 --- a/packages/floating-ui-svelte/src/components/floating-arrow.svelte +++ b/packages/floating-ui-svelte/src/components/floating-arrow.svelte @@ -1,178 +1,184 @@ width ? height : width}`} - aria-hidden="true" - style={styleObjectToString({ - position: 'absolute', - 'pointer-events': 'none', + bind:this={ref} + width={isCustomShape ? width : width + computedStrokeWidth} + height={width} + viewBox={`0 0 ${width} ${height > width ? height : width}`} + aria-hidden="true" + style={styleObjectToString({ + position: "absolute", + "pointer-events": "none", [xOffsetProp]: `${arrowX}`, [yOffsetProp]: `${arrowY}`, - [side]: isVerticalSide || isCustomShape ? '100%' : `calc(100% - ${computedStrokeWidth / 2}px)`, - transform: `${rotation} ${transform ?? ''}`, + [side]: + isVerticalSide || isCustomShape + ? "100%" + : `calc(100% - ${computedStrokeWidth / 2}px)`, + transform: `${rotation} ${transform ?? ""}`, fill, })} - data-testid="floating-arrow" - {...rest} -> - {#if computedStrokeWidth > 0} - - - {/if} - + + {/if} + - - - - - - \ No newline at end of file + + + + + + diff --git a/packages/floating-ui-svelte/src/components/floating-tree/floating-node.svelte b/packages/floating-ui-svelte/src/components/floating-tree/floating-node.svelte new file mode 100644 index 00000000..ae6de6b5 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-tree/floating-node.svelte @@ -0,0 +1,29 @@ + + + + +{@render children?.()} diff --git a/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte new file mode 100644 index 00000000..85a80a10 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte @@ -0,0 +1,37 @@ + + + + +{@render children?.()} diff --git a/packages/floating-ui-svelte/src/components/floating-tree/hooks.svelte.ts b/packages/floating-ui-svelte/src/components/floating-tree/hooks.svelte.ts new file mode 100644 index 00000000..2ee04a76 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-tree/hooks.svelte.ts @@ -0,0 +1,47 @@ +import { useId } from "../../hooks/use-id.js"; +import { Context } from "../../internal/context.js"; +import type { FloatingNodeType, FloatingTreeType } from "../../types.js"; + +export const FloatingNodeContext = new Context( + "FloatingNodeContext", +); + +export const FloatingTreeContext = new Context( + "FloatingTreeContext", +); + +/** + * Returns the parent node id for nested floating elements, if available. + * Returns `null` for top-level floating elements. + */ +export function useFloatingParentNodeId(): string | null { + return FloatingNodeContext.getOr(null)?.id || null; +} + +/** + * Returns the nearest floating tree context, if available. + */ +export function useFloatingTree(): FloatingTreeType | null { + return FloatingTreeContext.getOr(null); +} + +/** + * Registers a node into the `FloatingTree`, returning its id. + * @see https://floating-ui-svelte.vercel.app/docs/api/use-floating-node-id + */ +export function useFloatingNodeId(customParentId?: string): string | undefined { + const id = useId(); + const tree = useFloatingTree(); + const _parentId = useFloatingParentNodeId(); + const parentId = customParentId || _parentId; + + $effect(() => { + const node = { id, parentId }; + tree?.addNode(node); + return () => { + tree?.removeNode(node); + }; + }); + + return id; +} diff --git a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts index a2418f60..f8ef62af 100644 --- a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts @@ -1,7 +1,7 @@ import { isHTMLElement } from "@floating-ui/utils/dom"; import { isMouseLikePointerType } from "../internal/dom.js"; -import { isTypeableElement } from "../internal/is-typable-element.js"; -import type { FloatingContext } from "./use-floating.svelte.js"; +import { isTypeableElement } from "../internal/is-typeable-element.js"; +import type { FloatingContext } from "./use-position.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; interface UseClickOptions { diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index 583e446c..a6bbe1fd 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -13,7 +13,7 @@ import { isEventTargetWithin, isRootElement, } from "../internal/dom.js"; -import type { FloatingContext } from "./use-floating.svelte.js"; +import type { FloatingContext } from "./use-position.svelte.js"; const bubbleHandlerKeys = { pointerdown: "onpointerdown", diff --git a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts new file mode 100644 index 00000000..ff4cf0b3 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts @@ -0,0 +1,72 @@ +import type { ReferenceElement } from "@floating-ui/dom"; +import type { + ContextData, + FloatingElements, + FloatingEvents, + OpenChangeReason, +} from "../types.js"; +import { useId } from "./use-id.js"; +import { createPubSub } from "../internal/create-pub-sub.js"; +import { useFloatingParentNodeId } from "../components/floating-tree/hooks.svelte.js"; +import { DEV } from "esm-env"; +import { isElement } from "@floating-ui/utils/dom"; +import { error } from "../internal/log.js"; + +interface UseFloatingRootContextOptions { + open?: boolean; + onOpenChange?: ( + open: boolean, + event?: Event, + reason?: OpenChangeReason, + ) => void; + elements: { + reference: Element | null; + floating: HTMLElement | null; + }; +} + +interface FloatingRootContext { + data: ContextData; + open: boolean; + onOpenChange: ( + open: boolean, + event?: Event, + reason?: OpenChangeReason, + ) => void; + elements: FloatingElements; + events: FloatingEvents; + floatingId: string | undefined; + refs: { + setPositionReference(node: ReferenceElement | null): void; + }; +} + +export function useFloatingRootContext( + options: UseFloatingRootContextOptions, +): FloatingRootContext { + const { + open = false, + onOpenChange: onOpenChangeProp, + elements: elementsProp, + } = options; + + const floatingId = useId(); + const data = $state({}); + const events = createPubSub(); + const nested = useFloatingParentNodeId() != null; + + if (DEV) { + const optionDomReference = elementsProp.reference; + if (optionDomReference && !isElement(optionDomReference)) { + error( + "Cannot pass a virtual element to the `elements.reference` option,", + "as it must be a real DOM element. Use `refs.setPositionReference()`", + "instead.", + ); + } + } + + const positionReference = $state( + elementsProp.reference, + ); +} diff --git a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts index a3a87d84..31743ce4 100644 --- a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts @@ -8,9 +8,8 @@ import { isVirtualPointerEvent, } from "../internal/dom.js"; import { isMac, isSafari } from "../internal/environment.js"; -import { isTypeableElement } from "../internal/is-typable-element.js"; -import type { OpenChangeReason } from "../types.js"; -import type { FloatingContext } from "./use-floating.svelte.js"; +import { isTypeableElement } from "../internal/is-typeable-element.js"; +import type { FloatingContext, OpenChangeReason } from "../types.js"; interface UseFocusOptions { /** diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index 48171e03..11474394 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -7,7 +7,7 @@ import { } from "../internal/dom.js"; import { noop } from "../internal/noop.js"; import type { OpenChangeReason } from "../internal/types.js"; -import type { FloatingContext } from "./use-floating.svelte.js"; +import type { FloatingContext } from "./use-position.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; interface DelayOptions { diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts similarity index 56% rename from packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts rename to packages/floating-ui-svelte/src/hooks/use-position.svelte.ts index dfe3f1e0..f3379777 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts @@ -8,41 +8,23 @@ import { type Strategy, computePosition, } from "@floating-ui/dom"; -import { createPubSub } from "../internal/create-pub-sub.js"; import { getDPR, roundByDPR } from "../internal/dpr.js"; -import { noop } from "../internal/noop.js"; import { styleObjectToString } from "../internal/style-object-to-string.js"; -import type { OpenChangeReason } from "../types.js"; -import { useId } from "./use-id.js"; +import type { + ContextData, + ExtendedElements, + FloatingElements, + FloatingEvents, + OpenChangeReason, +} from "../types.js"; -interface FloatingElements { - /** - * The reference element. - */ - reference?: ReferenceElement | null; - - /** - * The floating element. - */ - floating?: FloatingElement | null; -} - -interface UseFloatingOptions { +interface UsePositionOptions { /** * Represents the open/close state of the floating element. * @default true */ open?: boolean; - /** - * Callback that is called whenever the open state changes. - */ - onOpenChange?: ( - open: boolean, - event?: Event, - reason?: OpenChangeReason, - ) => void; - /** * Where to place the floating element relative to its reference element. * @default 'bottom' @@ -91,7 +73,7 @@ interface UseFloatingOptions { nodeId?: string; } -interface UseFloatingData { +interface UsePositionData { /** * The x-coordinate of the floating element. */ @@ -123,95 +105,32 @@ interface UseFloatingData { isPositioned: boolean; } -interface FloatingEvents { - // biome-ignore lint/suspicious/noExplicitAny: From the port - emit(event: T, data?: any): void; - // biome-ignore lint/suspicious/noExplicitAny: From the port - on(event: string, handler: (data: any) => void): void; - // biome-ignore lint/suspicious/noExplicitAny: From the port - off(event: string, handler: (data: any) => void): void; -} - -interface ContextData { - /** - * The latest even that caused the open state to change. - */ - openEvent?: Event; - - /** - * Arbitrary data produced and consumed by other hooks. - */ - [key: string]: unknown; -} - -interface FloatingContext extends UseFloatingData { - /** - * Represents the open/close state of the floating element. - */ - open: boolean; - - /** - * Callback that is called whenever the open state changes. - */ - onOpenChange(open: boolean, event?: Event, reason?: OpenChangeReason): void; - - /** - * Events for other hooks to consume. - */ - events: FloatingEvents; - - /** - * Arbitrary data produced and consumer by other hooks. - */ - data: ContextData; - +interface UsePositionReturn { /** - * The id for the reference element - */ - nodeId: string | undefined; - - /** - * The id for the floating element - */ - floatingId: string; - - /** - * Object containing the floating and reference elements. - */ - elements: FloatingElements; -} - -interface UseFloatingReturn extends UseFloatingData { - /** - * Represents the open/close state of the floating element. + * The reference and floating elements. */ - readonly open: boolean; + readonly elements: FloatingElements; /** * CSS styles to apply to the floating element to position it. */ readonly floatingStyles: string; - /** - * The reference and floating elements. - */ - readonly elements: FloatingElements; - /** * Updates the floating element position. */ readonly update: () => Promise; /** - * Additional context meant for other hooks to consume. + * The computed position state of the floating element */ - readonly context: FloatingContext; + readonly state: UsePositionData; } /** * Hook for managing floating elements. */ -function useFloating(options: UseFloatingOptions = {}): UseFloatingReturn { +function usePosition(options: UsePositionOptions = {}): UsePositionReturn { const elements = $state(options.elements ?? {}); const { placement = "bottom", @@ -219,7 +138,6 @@ function useFloating(options: UseFloatingOptions = {}): UseFloatingReturn { middleware = [], transform = true, open = true, - onOpenChange: unstableOnOpenChange = noop, whileElementsMounted, nodeId, } = $derived(options); @@ -252,20 +170,7 @@ function useFloating(options: UseFloatingOptions = {}): UseFloatingReturn { }); }); - const events = createPubSub(); - const data: ContextData = $state({}); - - const onOpenChange = ( - open: boolean, - event?: Event, - reason?: OpenChangeReason, - ) => { - data.openEvent = open ? event : undefined; - events.emit("openchange", { open, event, reason }); - unstableOnOpenChange(open, event, reason); - }; - - const state: UseFloatingData = $state({ + const state: UsePositionData = $state({ x: 0, y: 0, strategy, @@ -274,38 +179,6 @@ function useFloating(options: UseFloatingOptions = {}): UseFloatingReturn { isPositioned: false, }); - const context: FloatingContext = $state({ - data, - events, - elements, - onOpenChange, - floatingId: useId(), - get nodeId() { - return nodeId; - }, - get x() { - return state.x; - }, - get y() { - return state.y; - }, - get placement() { - return state.placement; - }, - get strategy() { - return state.strategy; - }, - get middlewareData() { - return state.middlewareData; - }, - get isPositioned() { - return state.isPositioned; - }, - get open() { - return open; - }, - }); - const update = async () => { if (!elements.floating || !elements.reference) { return; @@ -368,34 +241,13 @@ function useFloating(options: UseFloatingOptions = {}): UseFloatingReturn { return { update, - context, elements, - get x() { - return state.x; - }, - get y() { - return state.y; - }, - get placement() { - return state.placement; - }, - get strategy() { - return state.strategy; - }, - get middlewareData() { - return state.middlewareData; - }, - get isPositioned() { - return state.isPositioned; - }, - get open() { - return open; - }, + state, get floatingStyles() { return floatingStyles; }, }; } -export type { UseFloatingOptions, UseFloatingReturn, FloatingContext }; -export { useFloating }; +export type { UsePositionOptions, UsePositionReturn, UsePositionData }; +export { usePosition }; diff --git a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts index 1ef66000..21f75657 100644 --- a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts @@ -1,4 +1,4 @@ -import type { FloatingContext } from "./use-floating.svelte.js"; +import type { FloatingContext } from "../types.js"; import { useId } from "./use-id.js"; import type { ElementProps, diff --git a/packages/floating-ui-svelte/src/index.ts b/packages/floating-ui-svelte/src/index.ts index fec9bd7c..5c8075b2 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -9,7 +9,7 @@ export { default as FloatingArrow } from "./components/floating-arrow.svelte"; */ export * from "./hooks/use-click.svelte.js"; export * from "./hooks/use-dismiss.svelte.js"; -export * from "./hooks/use-floating.svelte.js"; +export * from "./hooks/use-position.svelte.js"; export * from "./hooks/use-focus.svelte.js"; export * from "./hooks/use-hover.svelte.js"; export * from "./hooks/use-id.js"; diff --git a/packages/floating-ui-svelte/src/internal/context.ts b/packages/floating-ui-svelte/src/internal/context.ts new file mode 100644 index 00000000..b57c4dc9 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/context.ts @@ -0,0 +1,76 @@ +/** + * Sourced from Runed `Context`: https://runed.dev/docs/utilities/context + */ + +import { getContext, hasContext, setContext } from "svelte"; + +export class Context { + readonly #name: string; + readonly #key: symbol; + + /** + * @param name The name of the context. + * This is used for generating the context key and error messages. + */ + constructor(name: string) { + this.#name = name; + this.#key = Symbol(name); + } + + /** + * The key used to get and set the context. + * + * It is not recommended to use this value directly. + * Instead, use the methods provided by this class. + */ + get key(): symbol { + return this.#key; + } + + /** + * Checks whether this has been set in the context of a parent component. + * + * Must be called during component initialization. + */ + exists(): boolean { + return hasContext(this.#key); + } + + /** + * Retrieves the context that belongs to the closest parent component. + * + * Must be called during component initialization. + * + * @throws An error if the context does not exist. + */ + get(): TContext { + const context: TContext | undefined = getContext(this.#key); + if (context === undefined) { + throw new Error(`Context "${this.#name}" not found`); + } + return context; + } + + /** + * Retrieves the context that belongs to the closest parent component, + * or the given fallback value if the context does not exist. + * + * Must be called during component initialization. + */ + getOr(fallback: TFallback): TContext | TFallback { + const context: TContext | undefined = getContext(this.#key); + if (context === undefined) { + return fallback; + } + return context; + } + + /** + * Associates the given value with the current component and returns it. + * + * Must be called during component initialization. + */ + set(context: TContext): TContext { + return setContext(this.#key, context); + } +} diff --git a/packages/floating-ui-svelte/src/internal/is-typable-element.ts b/packages/floating-ui-svelte/src/internal/is-typeable-element.ts similarity index 100% rename from packages/floating-ui-svelte/src/internal/is-typable-element.ts rename to packages/floating-ui-svelte/src/internal/is-typeable-element.ts diff --git a/packages/floating-ui-svelte/src/internal/log.ts b/packages/floating-ui-svelte/src/internal/log.ts new file mode 100644 index 00000000..543bbbe4 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/log.ts @@ -0,0 +1,22 @@ +import { DEV } from "esm-env"; + +let devMessageSet: Set | undefined; +if (DEV) { + devMessageSet = new Set(); +} + +export function warn(...messages: string[]) { + const message = `Floating UI Svelte: ${messages.join(" ")}`; + if (!devMessageSet?.has(message)) { + devMessageSet?.add(message); + console.warn(message); + } +} + +export function error(...messages: string[]) { + const message = `Floating UI Svelte: ${messages.join(" ")}`; + if (!devMessageSet?.has(message)) { + devMessageSet?.add(message); + console.error(message); + } +} diff --git a/packages/floating-ui-svelte/src/types.ts b/packages/floating-ui-svelte/src/types.ts index 4b19995d..ea59c7bd 100644 --- a/packages/floating-ui-svelte/src/types.ts +++ b/packages/floating-ui-svelte/src/types.ts @@ -1,3 +1,9 @@ +import type { FloatingElement, ReferenceElement } from "@floating-ui/dom"; +import type { + UsePositionData, + UsePositionReturn, +} from "./hooks/use-position.svelte.js"; + type OpenChangeReason = | "outside-press" | "escape-key" @@ -9,4 +15,117 @@ type OpenChangeReason = | "list-navigation" | "safe-polygon"; -export type { OpenChangeReason }; +interface FloatingEvents { + // biome-ignore lint/suspicious/noExplicitAny: From the port + emit(event: T, data?: any): void; + // biome-ignore lint/suspicious/noExplicitAny: From the port + on(event: string, handler: (data: any) => void): void; + // biome-ignore lint/suspicious/noExplicitAny: From the port + off(event: string, handler: (data: any) => void): void; +} + +interface FloatingElements { + /** + * The reference element. + */ + reference?: ReferenceElement | null; + + /** + * The floating element. + */ + floating?: FloatingElement | null; +} + +interface ExtendedElements extends FloatingElements { + /** + * Some hooks require the reference element to be a DOM element, + * not a VirtualElement. + */ + domReference?: Element | null; +} + +interface ContextData { + /** + * The latest even that caused the open state to change. + */ + openEvent?: Event; + + floatingContext?: FloatingContext; + + /** + * Arbitrary data produced and consumed by other hooks. + */ + [key: string]: unknown; +} + +interface FloatingContext extends Omit { + /** + * Represents the open/close state of the floating element. + */ + open: boolean; + + /** + * Callback that is called whenever the open state changes. + */ + onOpenChange(open: boolean, event?: Event, reason?: OpenChangeReason): void; + + /** + * Events for other hooks to consume. + */ + events: FloatingEvents; + + /** + * Arbitrary data produced and consumer by other hooks. + */ + data: ContextData; + + /** + * The id for the reference element + */ + nodeId: string | undefined; + + /** + * The id for the floating element + */ + floatingId: string; + + /** + * Object containing the floating and reference elements. + */ + elements: ExtendedElements; +} + +interface FloatingNodeType { + /** + * The unique id for the node. + */ + id: string | undefined; + + /** + * The parent id for the node. + */ + parentId: string | null; + + /** + * An optional context object that can be used to pass data between hooks. + */ + context?: FloatingContext; +} + +interface FloatingTreeType { + nodes: FloatingNodeType[]; + events: FloatingEvents; + addNode(node: FloatingNodeType): void; + removeNode(node: FloatingNodeType): void; +} + +export type { + OpenChangeReason, + FloatingEvents, + FloatingElements, + ContextData, + ExtendedElements, + FloatingContext, + FloatingNodeType, + FloatingTreeType, +}; diff --git a/packages/floating-ui-svelte/test/components/floating-arrow.ts b/packages/floating-ui-svelte/test/components/floating-arrow.ts index 053b210c..e5504de1 100644 --- a/packages/floating-ui-svelte/test/components/floating-arrow.ts +++ b/packages/floating-ui-svelte/test/components/floating-arrow.ts @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/svelte"; import { describe, expect, it } from "vitest"; import FloatingArrow from "../../src/components/floating-arrow.svelte"; -import { useFloating } from "../../src/hooks/use-floating.svelte.js"; +import { useFloating } from "../../src/hooks/use-position.svelte.js"; import { withRunes } from "../internal/with-runes.svelte.js"; describe("FloatingArrow", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1c0e7ad..0b398e66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@floating-ui/utils': specifier: ^0.2.8 version: 0.2.8 + esm-env: + specifier: ^1.2.1 + version: 1.2.1 devDependencies: '@sveltejs/kit': specifier: ^2.15.1 From 72ec579053f8dee27515db2ccd0d27f810d0838e Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 8 Jan 2025 18:10:24 -0500 Subject: [PATCH 02/87] some stuff --- .gitignore | 1 - .vscode/settings.json | 6 + .../hooks/use-floating-root-context.svelte.ts | 81 +++++++++++-- .../src/hooks/use-floating.svelte.ts | 108 ++++++++++++++++++ .../src/hooks/use-position.svelte.ts | 52 +++++---- packages/floating-ui-svelte/src/types.ts | 19 +-- 6 files changed, 222 insertions(+), 45 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts diff --git a/.gitignore b/.gitignore index f9467c03..30e84771 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # IDE's .idea -.vscode # Artifacts node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..eeebd611 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[typescript][javascript][json][html][css][jsonc][markdown][typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + } +} \ No newline at end of file diff --git a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts index ff4cf0b3..489f0706 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts @@ -1,9 +1,9 @@ import type { ReferenceElement } from "@floating-ui/dom"; import type { ContextData, - FloatingElements, FloatingEvents, OpenChangeReason, + ReferenceType, } from "../types.js"; import { useId } from "./use-id.js"; import { createPubSub } from "../internal/create-pub-sub.js"; @@ -11,6 +11,7 @@ import { useFloatingParentNodeId } from "../components/floating-tree/hooks.svelt import { DEV } from "esm-env"; import { isElement } from "@floating-ui/utils/dom"; import { error } from "../internal/log.js"; +import { noop } from "../internal/noop.js"; interface UseFloatingRootContextOptions { open?: boolean; @@ -25,7 +26,7 @@ interface UseFloatingRootContextOptions { }; } -interface FloatingRootContext { +interface FloatingRootContext { data: ContextData; open: boolean; onOpenChange: ( @@ -33,22 +34,29 @@ interface FloatingRootContext { event?: Event, reason?: OpenChangeReason, ) => void; - elements: FloatingElements; + elements: { + domReference: Element | null; + reference: RT | null; + floating: HTMLElement | null; + }; events: FloatingEvents; floatingId: string | undefined; refs: { - setPositionReference(node: ReferenceElement | null): void; + setPositionReference(node: ReferenceType | null): void; }; } -export function useFloatingRootContext( +/** + * Creates a floating root context to manage the state of a floating element. + */ +function useFloatingRootContext( options: UseFloatingRootContextOptions, ): FloatingRootContext { - const { - open = false, - onOpenChange: onOpenChangeProp, - elements: elementsProp, - } = options; + const elementsProp: { + reference: ReferenceType | null; + floating: HTMLElement | null; + } = $state(options.elements); + const { open = false, onOpenChange: onOpenChangeProp = noop } = options; const floatingId = useId(); const data = $state({}); @@ -66,7 +74,58 @@ export function useFloatingRootContext( } } - const positionReference = $state( + // Enable the user to set the position reference later to something other than + // what it was initialized with + let positionReference = $state( elementsProp.reference, ); + + const onOpenChange = ( + open: boolean, + event?: Event, + reason?: OpenChangeReason, + ) => { + data.openEvent = open ? event : undefined; + events.emit("openchange", { open, event, reason, nested }); + onOpenChangeProp(open, event, reason); + }; + + const _elements = $derived({ + reference: positionReference || elementsProp.reference || null, + floating: elementsProp.floating || null, + domReference: elementsProp.reference as Element | null, + }); + + return { + data, + open, + onOpenChange, + elements: { + get reference() { + return _elements.reference; + }, + set reference(v: ReferenceType | null) { + elementsProp.reference = v; + }, + get floating() { + return _elements.floating; + }, + set floating(v: HTMLElement | null) { + elementsProp.floating = v; + }, + get domReference() { + return _elements.domReference; + }, + }, + events, + floatingId, + refs: { + setPositionReference(node: ReferenceElement | null) { + positionReference = node; + }, + }, + }; } + +export { useFloatingRootContext }; +export type { FloatingRootContext, UseFloatingRootContextOptions }; diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts new file mode 100644 index 00000000..3b751d24 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -0,0 +1,108 @@ +import { isElement } from "@floating-ui/utils/dom"; +import { useFloatingTree } from "../components/floating-tree/hooks.svelte.js"; +import type { + NarrowedElement, + OpenChangeReason, + ReferenceType, +} from "../types.js"; +import { + useFloatingRootContext, + type FloatingRootContext, +} from "./use-floating-root-context.svelte.js"; +import { usePosition, type UsePositionOptions } from "./use-position.svelte.js"; + +interface UseFloatingOptions + extends Omit, "elements"> { + rootContext?: FloatingRootContext; + /** + * Object of external elements as an alternative to the `refs` object setters. + */ + elements?: { + /** + * The reference element. + */ + reference?: Element | null; + /** + * The floating element. + */ + floating?: HTMLElement | null; + }; + /** + * An event callback that is invoked when the floating element is opened or + * closed. + */ + onOpenChange?(open: boolean, event?: Event, reason?: OpenChangeReason): void; + /** + * Unique node id when using `FloatingTree`. + */ + nodeId?: string; +} + +/** + * Provides data to position a floating element and context to add interactions. + */ + +export function useFloating( + options: UseFloatingOptions = {}, +) { + const elements = $state({ + reference: options.elements?.reference ?? null, + floating: options.elements?.floating ?? null, + domReference: null as NarrowedElement | null, + }); + const { nodeId } = options; + + const internalRootContext = useFloatingRootContext({ + ...options, + elements: elements, + }); + + const rootContext = options.rootContext ?? internalRootContext; + const computedElements = rootContext.elements; + + let _domReference = $state | null>(null); + let positionReference = $state(null); + const optionDomReference = $derived(computedElements.domReference); + const domReference = $derived( + optionDomReference ?? _domReference, + ) as NarrowedElement; + + const tree = useFloatingTree(); + + $effect.pre(() => { + if (domReference) { + elements.domReference = domReference; + } + }); + + const position = usePosition({ + ...options, + elements: { + get floating() { + return computedElements.floating; + }, + get reference() { + if (positionReference) { + return positionReference; + } + return computedElements.reference; + }, + }, + }); + + function setPositionReference(node: ReferenceType | null) { + const computedPositionReference = isElement(node) + ? { + getBoundingClientRect: () => node.getBoundingClientRect(), + contextElement: node, + } + : node; + positionReference = computedPositionReference; + } + + function setReference(node: RT | null) { + if (isElement(node) || node === null) { + (_domReference as Element | null) = node; + } + } +} diff --git a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts index f3379777..26ec6047 100644 --- a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts @@ -1,24 +1,28 @@ import { type ComputePositionConfig, - type FloatingElement, type Middleware, type MiddlewareData, type Placement, - type ReferenceElement, type Strategy, computePosition, } from "@floating-ui/dom"; import { getDPR, roundByDPR } from "../internal/dpr.js"; import { styleObjectToString } from "../internal/style-object-to-string.js"; -import type { - ContextData, - ExtendedElements, - FloatingElements, - FloatingEvents, - OpenChangeReason, -} from "../types.js"; +import type { ReferenceType } from "../types.js"; -interface UsePositionOptions { +interface PositionElements { + /** + * The reference element. + */ + reference?: RT | null; + + /** + * The floating element. + */ + floating?: HTMLElement | null; +} + +interface UsePositionOptions { /** * Represents the open/close state of the floating element. * @default true @@ -54,23 +58,17 @@ interface UsePositionOptions { * Object containing the floating and reference elements. * @default {} */ - elements?: FloatingElements; + elements?: PositionElements; /** * Callback to handle mounting/unmounting of the elements. * @default undefined */ whileElementsMounted?: ( - reference: ReferenceElement, - floating: FloatingElement, + reference: RT, + floating: HTMLElement, update: () => void, ) => () => void; - - /** - * Unique node id when using `FloatingTree`. - * @default undefined - */ - nodeId?: string; } interface UsePositionData { @@ -105,11 +103,11 @@ interface UsePositionData { isPositioned: boolean; } -interface UsePositionReturn { +interface UsePositionReturn { /** * The reference and floating elements. */ - readonly elements: FloatingElements; + readonly elements: Required>; /** * CSS styles to apply to the floating element to position it. @@ -130,8 +128,13 @@ interface UsePositionReturn { /** * Hook for managing floating elements. */ -function usePosition(options: UsePositionOptions = {}): UsePositionReturn { - const elements = $state(options.elements ?? {}); +function usePosition( + options: UsePositionOptions = {}, +): UsePositionReturn { + const elements = $state({ + reference: (options.elements?.reference ?? null) as RT | null, + floating: options.elements?.floating ?? null, + }); const { placement = "bottom", strategy = "absolute", @@ -139,7 +142,6 @@ function usePosition(options: UsePositionOptions = {}): UsePositionReturn { transform = true, open = true, whileElementsMounted, - nodeId, } = $derived(options); const floatingStyles = $derived.by(() => { const initialStyles = { @@ -208,7 +210,7 @@ function usePosition(options: UsePositionOptions = {}): UsePositionReturn { if (!options.elements || !options.elements.reference) { return; } - elements.reference = options.elements.reference; + elements.reference = options.elements.reference as RT; }); $effect.pre(() => { diff --git a/packages/floating-ui-svelte/src/types.ts b/packages/floating-ui-svelte/src/types.ts index ea59c7bd..03aecdb4 100644 --- a/packages/floating-ui-svelte/src/types.ts +++ b/packages/floating-ui-svelte/src/types.ts @@ -1,8 +1,5 @@ -import type { FloatingElement, ReferenceElement } from "@floating-ui/dom"; -import type { - UsePositionData, - UsePositionReturn, -} from "./hooks/use-position.svelte.js"; +import type { FloatingElement, VirtualElement } from "@floating-ui/dom"; +import type { UsePositionReturn } from "./hooks/use-position.svelte.js"; type OpenChangeReason = | "outside-press" @@ -15,6 +12,10 @@ type OpenChangeReason = | "list-navigation" | "safe-polygon"; +type ReferenceType = Element | VirtualElement; + +type NarrowedElement = T extends Element ? T : Element; + interface FloatingEvents { // biome-ignore lint/suspicious/noExplicitAny: From the port emit(event: T, data?: any): void; @@ -28,7 +29,7 @@ interface FloatingElements { /** * The reference element. */ - reference?: ReferenceElement | null; + reference?: ReferenceType | null; /** * The floating element. @@ -36,12 +37,12 @@ interface FloatingElements { floating?: FloatingElement | null; } -interface ExtendedElements extends FloatingElements { +interface ExtendedElements extends Required { /** * Some hooks require the reference element to be a DOM element, * not a VirtualElement. */ - domReference?: Element | null; + domReference: Element | null; } interface ContextData { @@ -128,4 +129,6 @@ export type { FloatingContext, FloatingNodeType, FloatingTreeType, + ReferenceType, + NarrowedElement, }; From 6ff33390da8d34d161bee5fe498609959c3b4602 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 8 Jan 2025 19:56:18 -0500 Subject: [PATCH 03/87] urg --- package.json | 4 +- .../src/hooks/use-click.svelte.ts | 41 ++-- .../src/hooks/use-dismiss.svelte.ts | 60 +++--- .../hooks/use-floating-root-context.svelte.ts | 27 ++- .../src/hooks/use-floating.svelte.ts | 175 ++++++++++++++++-- .../src/hooks/use-focus.svelte.ts | 34 ++-- .../src/hooks/use-hover.svelte.ts | 68 +++---- .../src/hooks/use-position.svelte.ts | 27 ++- .../src/hooks/use-role.svelte.ts | 11 +- packages/floating-ui-svelte/src/index.ts | 2 +- packages/floating-ui-svelte/src/types.ts | 11 +- .../test/hooks/use-floating.svelte.ts | 14 +- .../hooks/wrapper-components/use-click.svelte | 85 +++++---- .../wrapper-components/use-dismiss.svelte | 65 ++++--- .../hooks/wrapper-components/use-focus.svelte | 65 ++++--- .../hooks/wrapper-components/use-hover.svelte | 77 ++++---- .../hooks/wrapper-components/use-role.svelte | 83 +++++---- 17 files changed, 504 insertions(+), 345 deletions(-) diff --git a/package.json b/package.json index d1da242f..c4d56f71 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "format:check": "biome check .", "build": "pnpm --recursive build", "build:watch": "pnpm --recursive --parallel build:watch", - "test": "pnpm --recursive test", - "test:watch": "pnpm --recursive test:watch", + "test": "pnpm --recursive --reporter append-only --color test", + "test:watch": "pnpm --recursive --reporter append-only --color test:watch", "sync": "pnpm --recursive sync", "postinstall": "pnpm sync" }, diff --git a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts index f8ef62af..4591e4ea 100644 --- a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts @@ -1,8 +1,8 @@ import { isHTMLElement } from "@floating-ui/utils/dom"; import { isMouseLikePointerType } from "../internal/dom.js"; import { isTypeableElement } from "../internal/is-typeable-element.js"; -import type { FloatingContext } from "./use-position.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; +import type { FloatingContext } from "../types.js"; interface UseClickOptions { /** @@ -56,13 +56,6 @@ function useClick( context: FloatingContext, options: UseClickOptions = {}, ): ElementProps { - const { - open, - onOpenChange, - data, - elements: { reference }, - } = $derived(context); - const { enabled = true, event: eventOption = "click", @@ -97,15 +90,17 @@ function useClick( } if ( - open && + context.open && toggle && - (data.openEvent ? data.openEvent.type === "mousedown" : true) + (context.data.openEvent + ? context.data.openEvent.type === "mousedown" + : true) ) { - onOpenChange(false, event, "click"); + context.onOpenChange(false, event, "click"); } else { // Prevent stealing focus from the floating element event.preventDefault(); - onOpenChange(true, event, "click"); + context.onOpenChange(true, event, "click"); } }, onclick: (event: MouseEvent) => { @@ -119,13 +114,15 @@ function useClick( } if ( - open && + context.open && toggle && - (data.openEvent ? data.openEvent.type === "click" : true) + (context.data.openEvent + ? context.data.openEvent.type === "click" + : true) ) { - onOpenChange(false, event, "click"); + context.onOpenChange(false, event, "click"); } else { - onOpenChange(true, event, "click"); + context.onOpenChange(true, event, "click"); } }, onkeydown: (event: KeyboardEvent) => { @@ -146,10 +143,10 @@ function useClick( } if (event.key === "Enter") { - if (open && toggle) { - onOpenChange(false, event, "click"); + if (context.open && toggle) { + context.onOpenChange(false, event, "click"); } else { - onOpenChange(true, event, "click"); + context.onOpenChange(true, event, "click"); } } }, @@ -166,10 +163,10 @@ function useClick( if (event.key === " " && didKeyDown) { didKeyDown = false; - if (open && toggle) { - onOpenChange(false, event, "click"); + if (context.open && toggle) { + context.onOpenChange(false, event, "click"); } else { - onOpenChange(true, event, "click"); + context.onOpenChange(true, event, "click"); } } }, diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index a6bbe1fd..28a51c52 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -13,7 +13,7 @@ import { isEventTargetWithin, isRootElement, } from "../internal/dom.js"; -import type { FloatingContext } from "./use-position.svelte.js"; +import type { FloatingContext } from "../types.js"; const bubbleHandlerKeys = { pointerdown: "onpointerdown", @@ -109,14 +109,6 @@ interface UseDismissOptions { } function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { - const { - open, - onOpenChange, - // nodeId, - elements: { reference, floating }, - data, - } = $derived(context); - const { enabled = true, escapeKey = true, @@ -148,7 +140,7 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { normalizeProp(capture); const closeOnEscapeKeyDown = (event: KeyboardEvent) => { - if (!open || !enabled || !escapeKey || event.key !== "Escape") { + if (!context.open || !enabled || !escapeKey || event.key !== "Escape") { return; } @@ -173,7 +165,7 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { // } } - onOpenChange(false, event, "escape-key"); + context.onOpenChange(false, event, "escape-key"); }; const closeOnEscapeKeyDownCapture = (event: KeyboardEvent) => { @@ -211,7 +203,9 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { const target = getTarget(event); const inertSelector = `[${createAttribute("inert")}]`; - const markers = getDocument(floating).querySelectorAll(inertSelector); + const markers = getDocument(context.elements.floating).querySelectorAll( + inertSelector, + ); let targetRootAncestor = isElement(target) ? target : null; while (targetRootAncestor && !isLastTraversableNode(targetRootAncestor)) { @@ -230,7 +224,7 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { isElement(target) && !isRootElement(target) && // Clicked on a direct ancestor (e.g. FloatingOverlay). - !contains(target, floating) && + !contains(target, context.elements.floating) && // If the target root element contains none of the markers, then the // element was injected after the floating element rendered. Array.from(markers).every( @@ -241,7 +235,7 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { } // Check if the click occurred on the scrollbar - if (isHTMLElement(target) && floating) { + if (isHTMLElement(target) && context.elements.floating) { // In Firefox, `target.scrollWidth > target.clientWidth` for inline // elements. const canScrollX = @@ -275,9 +269,9 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { // ); if ( - isEventTargetWithin(event, floating) || - // @ts-expect-error - FIXME - isEventTargetWithin(event, reference) + isEventTargetWithin(event, context.elements.floating) || + (isElement(context.elements.reference) && + isEventTargetWithin(event, context.elements.reference)) // targetIsInsideChildren ) { return; @@ -299,7 +293,7 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { // } // } - onOpenChange(false, event, "outside-press"); + context.onOpenChange(false, event, "outside-press"); }; const closeOnPressOutsideCapture = (event: MouseEvent) => { @@ -311,18 +305,18 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { }; $effect(() => { - if (!open || !enabled) { + if (!context.open || !enabled) { return; } - data.__escapeKeyBubbles = escapeKeyBubbles; - data.__outsidePressBubbles = outsidePressBubbles; + context.data.__escapeKeyBubbles = escapeKeyBubbles; + context.data.__outsidePressBubbles = outsidePressBubbles; function onScroll(event: Event) { - onOpenChange(false, event, "ancestor-scroll"); + context.onOpenChange(false, event, "ancestor-scroll"); } - const doc = getDocument(floating); + const doc = getDocument(context.elements.floating); escapeKey && doc.addEventListener( "keydown", @@ -339,17 +333,23 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { let ancestors: (Element | Window | VisualViewport)[] = []; if (ancestorScroll) { - if (isElement(reference)) { - ancestors = getOverflowAncestors(reference); + if (isElement(context.elements.reference)) { + ancestors = getOverflowAncestors(context.elements.reference); } - if (isElement(floating)) { - ancestors = ancestors.concat(getOverflowAncestors(floating)); + if (isElement(context.elements.floating)) { + ancestors = ancestors.concat( + getOverflowAncestors(context.elements.floating), + ); } - if (!isElement(reference) && reference && reference.contextElement) { + if ( + !isElement(context.elements.reference) && + context.elements.reference && + context.elements.reference.contextElement + ) { ancestors = ancestors.concat( - getOverflowAncestors(reference.contextElement), + getOverflowAncestors(context.elements.reference.contextElement), ); } } @@ -398,7 +398,7 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { onKeyDown: closeOnEscapeKeyDown, [bubbleHandlerKeys[referencePressEvent]]: (event: Event) => { if (referencePress) { - onOpenChange(false, event, "reference-press"); + context.onOpenChange(false, event, "reference-press"); } }, }; diff --git a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts index 489f0706..05f7f0da 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts @@ -56,6 +56,21 @@ function useFloatingRootContext( reference: ReferenceType | null; floating: HTMLElement | null; } = $state(options.elements); + + $effect.pre(() => { + if (!options.elements || !options.elements.reference) { + return; + } + elementsProp.reference = options.elements.reference; + }); + + $effect.pre(() => { + if (!options.elements || !options.elements.floating) { + return; + } + elementsProp.floating = options.elements.floating; + }); + const { open = false, onOpenChange: onOpenChangeProp = noop } = options; const floatingId = useId(); @@ -90,7 +105,7 @@ function useFloatingRootContext( onOpenChangeProp(open, event, reason); }; - const _elements = $derived({ + const elements = $derived({ reference: positionReference || elementsProp.reference || null, floating: elementsProp.floating || null, domReference: elementsProp.reference as Element | null, @@ -98,23 +113,25 @@ function useFloatingRootContext( return { data, - open, + get open() { + return open; + }, onOpenChange, elements: { get reference() { - return _elements.reference; + return elements.reference; }, set reference(v: ReferenceType | null) { elementsProp.reference = v; }, get floating() { - return _elements.floating; + return elements.floating; }, set floating(v: HTMLElement | null) { elementsProp.floating = v; }, get domReference() { - return _elements.domReference; + return elements.domReference; }, }, events, diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index 3b751d24..365af742 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -1,6 +1,8 @@ import { isElement } from "@floating-ui/utils/dom"; import { useFloatingTree } from "../components/floating-tree/hooks.svelte.js"; import type { + ExtendedElements, + FloatingContext, NarrowedElement, OpenChangeReason, ReferenceType, @@ -9,7 +11,11 @@ import { useFloatingRootContext, type FloatingRootContext, } from "./use-floating-root-context.svelte.js"; -import { usePosition, type UsePositionOptions } from "./use-position.svelte.js"; +import { + usePosition, + type UsePositionOptions, + type UsePositionReturn, +} from "./use-position.svelte.js"; interface UseFloatingOptions extends Omit, "elements"> { @@ -38,27 +44,71 @@ interface UseFloatingOptions nodeId?: string; } +interface UseFloatingReturn + extends UsePositionReturn { + /** + * `FloatingContext` + */ + context: FloatingContext; + + /** + * Set the position reference outside of the `elements` + * object. + */ + refs: { + setPositionReference(node: ReferenceType | null): void; + }; + + /** + * The floating elements. + */ + elements: ExtendedElements; +} + /** * Provides data to position a floating element and context to add interactions. */ export function useFloating( options: UseFloatingOptions = {}, -) { - const elements = $state({ +): UseFloatingReturn { + const elementsProp = $state({ reference: options.elements?.reference ?? null, floating: options.elements?.floating ?? null, domReference: null as NarrowedElement | null, }); + + $effect.pre(() => { + if (!options.elements || !options.elements.reference) { + return; + } + elementsProp.reference = options.elements.reference; + }); + + $effect.pre(() => { + if (!options.elements || !options.elements.floating) { + return; + } + elementsProp.floating = options.elements.floating; + }); + const { nodeId } = options; const internalRootContext = useFloatingRootContext({ - ...options, - elements: elements, + get open() { + if (options.open === undefined) return true; + return options.open; + }, + get elements() { + return { + reference: options.elements?.reference ?? null, + floating: options.elements?.floating ?? null, + }; + }, }); const rootContext = options.rootContext ?? internalRootContext; - const computedElements = rootContext.elements; + const computedElements = $derived(rootContext.elements); let _domReference = $state | null>(null); let positionReference = $state(null); @@ -71,7 +121,7 @@ export function useFloating( $effect.pre(() => { if (domReference) { - elements.domReference = domReference; + elementsProp.domReference = domReference; } }); @@ -90,19 +140,104 @@ export function useFloating( }, }); - function setPositionReference(node: ReferenceType | null) { - const computedPositionReference = isElement(node) - ? { - getBoundingClientRect: () => node.getBoundingClientRect(), - contextElement: node, - } - : node; - positionReference = computedPositionReference; - } + const elements = $state({ + get reference() { + return position.elements.reference; + }, + set reference(node: ReferenceType | null) { + if (isElement(node) || node === null) { + (_domReference as Element | null) = node; + } + }, + get floating() { + return position.elements.floating; + }, + set floating(node: HTMLElement | null) { + elementsProp.floating = node; + }, + get domReference() { + return domReference; + }, + }); + + const context = $state>({ + get x() { + return position.x; + }, + get y() { + return position.y; + }, + get placement() { + return position.placement; + }, + get strategy() { + return position.strategy; + }, + get middlewareData() { + return position.middlewareData; + }, + get isPositioned() { + return position.isPositioned; + }, + update: position.update, + get floatingStyles() { + return position.floatingStyles; + }, + data: rootContext.data, + floatingId: rootContext.floatingId, + events: rootContext.events, + elements, + nodeId, + onOpenChange: rootContext.onOpenChange, + get open() { + return rootContext.open; + }, + }); + + $effect(() => { + rootContext.data.floatingContext = context; - function setReference(node: RT | null) { - if (isElement(node) || node === null) { - (_domReference as Element | null) = node; + const node = tree?.nodes.find((node) => node.id === nodeId); + if (node) { + node.context = context; } - } + }); + + return { + context, + elements, + update: position.update, + get x() { + return position.x; + }, + get y() { + return position.y; + }, + get placement() { + return position.placement; + }, + get strategy() { + return position.strategy; + }, + get middlewareData() { + return position.middlewareData; + }, + get isPositioned() { + return position.isPositioned; + }, + get floatingStyles() { + return position.floatingStyles; + }, + refs: { + setPositionReference: (node: ReferenceType | null) => { + const computedPositionReference = isElement(node) + ? { + getBoundingClientRect: () => node.getBoundingClientRect(), + contextElement: node, + } + : node; + positionReference = computedPositionReference; + }, + }, + }; } diff --git a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts index 31743ce4..67388519 100644 --- a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts @@ -27,13 +27,6 @@ interface UseFocusOptions { } function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { - const { - open, - onOpenChange, - events, - elements: { reference, floating }, - } = $derived(context); - const { enabled = true, visibleOnly = true } = $derived(options); let blockFocus = false; @@ -45,7 +38,7 @@ function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { return; } - const win = getWindow(reference); + const win = getWindow(context.elements.reference); // If the reference was focused and the user left the tab/window, and the // floating element was not open, the focus should be blocked when they @@ -53,8 +46,9 @@ function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { function onBlur() { if ( !open && - isHTMLElement(reference) && - reference === activeElement(getDocument(reference)) + isHTMLElement(context.elements.reference) && + context.elements.reference === + activeElement(getDocument(context.elements.reference)) ) { blockFocus = true; } @@ -83,9 +77,9 @@ function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { } } - events.on("openchange", onOpenChange); + context.events.on("openchange", onOpenChange); return () => { - events.off("openchange", onOpenChange); + context.events.off("openchange", onOpenChange); }; }); @@ -130,7 +124,7 @@ function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { } } - onOpenChange(true, event, "focus"); + context.onOpenChange(true, event, "focus"); }, onblur: (event: FocusEvent) => { blockFocus = false; @@ -146,12 +140,14 @@ function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { // Wait for the window blur listener to fire. timeout = window.setTimeout(() => { const activeEl = activeElement( - // @ts-expect-error - FIXME - reference ? reference.ownerDocument : document, + isElement(context.elements.reference) + ? context.elements.reference.ownerDocument + : document, ); // Focus left the page, keep it open. - if (!relatedTarget && activeEl === reference) return; + if (!relatedTarget && activeEl === context.elements.reference) + return; // When focusing the reference element (e.g. regular click), then // clicking into the floating element, prevent it from hiding. @@ -161,15 +157,15 @@ function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { // and not the element that actually has received focus if it is located // inside a shadow root. if ( - contains(floating, activeEl) || + contains(context.elements.floating, activeEl) || // @ts-expect-error FIXME - contains(reference, activeEl) || + contains(context.elements.reference, activeEl) || movedToFocusGuard ) { return; } - onOpenChange(false, event, "focus"); + context.onOpenChange(false, event, "focus"); }); }, }; diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index 11474394..89dabf70 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -6,9 +6,8 @@ import { isMouseLikePointerType, } from "../internal/dom.js"; import { noop } from "../internal/noop.js"; -import type { OpenChangeReason } from "../internal/types.js"; -import type { FloatingContext } from "./use-position.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; +import type { FloatingContext, OpenChangeReason } from "../types.js"; interface DelayOptions { /** @@ -96,14 +95,6 @@ function useHover( context: FloatingContext, options: UseHoverOptions = {}, ): ElementProps { - const { - open, - onOpenChange, - data, - events, - elements: { reference, floating }, - } = $derived(context); - const { enabled = true, mouseOnly = false, @@ -124,13 +115,13 @@ function useHover( let unbindMouseMove = noop; const isHoverOpen = $derived.by(() => { - const type = data.openEvent?.type; + const type = context.data.openEvent?.type; return type?.includes("mouse") && type !== "mousedown"; }); const isClickLikeOpenEvent = $derived( - data.openEvent - ? ["click", "mousedown"].includes(data.openEvent.type) + context.data.openEvent + ? ["click", "mousedown"].includes(context.data.openEvent.type) : false, ); @@ -147,9 +138,9 @@ function useHover( } }; - events.on("openchange", onOpenChange); + context.events.on("openchange", onOpenChange); return () => { - events.off("openchange", onOpenChange); + context.events.off("openchange", onOpenChange); }; }); @@ -162,10 +153,10 @@ function useHover( if (!isHoverOpen) { return; } - onOpenChange(false, event, "hover"); + context.onOpenChange(false, event, "hover"); }; - const document = getDocument(floating); + const document = getDocument(context.elements.floating); document.addEventListener("mouseleave", onLeave); return () => { document.removeEventListener("mouseleave", onLeave); @@ -181,12 +172,12 @@ function useHover( if (closeDelay && !handler) { clearTimeout(timeout); timeout = window.setTimeout( - () => onOpenChange(false, event, reason), + () => context.onOpenChange(false, event, reason), closeDelay, ); } else if (runElseBranch) { clearTimeout(timeout); - onOpenChange(false, event, reason); + context.onOpenChange(false, event, reason); } }; @@ -199,7 +190,7 @@ function useHover( if (!performedPointerEventsMutation) { return; } - const body = getDocument(floating).body; + const body = getDocument(context.elements.floating).body; body.style.pointerEvents = ""; body.removeAttribute(safePolygonIdentifier); performedPointerEventsMutation = false; @@ -214,14 +205,20 @@ function useHover( return; } - if (open && handleClose?.__options.blockPointerEvents && isHoverOpen) { - const body = getDocument(floating).body; + if ( + context.open && + handleClose?.__options.blockPointerEvents && + isHoverOpen + ) { + const body = getDocument(context.elements.floating).body; body.setAttribute(safePolygonIdentifier, ""); body.style.pointerEvents = "none"; performedPointerEventsMutation = true; - if (isElement(reference) && floating) { - const ref = reference as unknown as HTMLElement | SVGSVGElement; + if (isElement(context.elements.reference) && context.elements.floating) { + const ref = context.elements.reference as unknown as + | HTMLElement + | SVGSVGElement; // const parentFloating = tree?.nodesRef.current.find((node) => node.id === parentId)?.context // ?.elements.floating; @@ -231,11 +228,13 @@ function useHover( // } ref.style.pointerEvents = "auto"; - floating.style.pointerEvents = "auto"; + context.elements.floating.style.pointerEvents = "auto"; return () => { ref.style.pointerEvents = ""; - floating.style.pointerEvents = ""; + if (context.elements.floating) { + context.elements.floating.style.pointerEvents = ""; + } }; } } @@ -279,10 +278,10 @@ function useHover( if (openDelay) { timeout = window.setTimeout(() => { - onOpenChange(true, event, "hover"); + context.onOpenChange(true, event, "hover"); }, openDelay); } else { - onOpenChange(true, event, "hover"); + context.onOpenChange(true, event, "hover"); } }; return { @@ -299,7 +298,7 @@ function useHover( } function handleMouseMove() { if (!blockMouseMove) { - onOpenChange(true, event, "hover"); + context.onOpenChange(true, event, "hover"); } } @@ -307,7 +306,7 @@ function useHover( return; } - if (open || restMs === 0) { + if (context.open || restMs === 0) { return; } @@ -323,7 +322,7 @@ function useHover( if (!isClickLikeOpenEvent) { unbindMouseMove(); - const doc = getDocument(floating); + const doc = getDocument(context.elements.floating); clearTimeout(restTimeout); if (handleClose) { @@ -359,14 +358,17 @@ function useHover( // consistently. const shouldClose = pointerType === "touch" - ? !contains(floating, event.relatedTarget as Element | null) + ? !contains( + context.elements.floating, + event.relatedTarget as Element | null, + ) : true; if (shouldClose) { closeWithDelay(event); } } - if (open && !isClickLikeOpenEvent) { + if (context.open && !isClickLikeOpenEvent) { handleClose?.({ ...context, // tree, diff --git a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts index 26ec6047..513d68cf 100644 --- a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts @@ -103,7 +103,8 @@ interface UsePositionData { isPositioned: boolean; } -interface UsePositionReturn { +interface UsePositionReturn + extends UsePositionData { /** * The reference and floating elements. */ @@ -118,11 +119,6 @@ interface UsePositionReturn { * Updates the floating element position. */ readonly update: () => Promise; - - /** - * The computed position state of the floating element - */ - readonly state: UsePositionData; } /** @@ -244,7 +240,24 @@ function usePosition( return { update, elements, - state, + get x() { + return state.x; + }, + get y() { + return state.y; + }, + get placement() { + return state.placement; + }, + get strategy() { + return state.strategy; + }, + get middlewareData() { + return state.middlewareData; + }, + get isPositioned() { + return state.isPositioned; + }, get floatingStyles() { return floatingStyles; }, diff --git a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts index 21f75657..fa1318d0 100644 --- a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts @@ -1,4 +1,5 @@ import type { FloatingContext } from "../types.js"; +import type { FloatingRootContext } from "./use-floating-root-context.svelte.js"; import { useId } from "./use-id.js"; import type { ElementProps, @@ -43,8 +44,6 @@ function useRole( context: FloatingContext, options: UseRoleOptions = {}, ): ElementProps { - const { open, floatingId } = $derived(context); - const { enabled = true, role = "dialog" } = $derived(options); const ariaRole = $derived( @@ -62,7 +61,7 @@ function useRole( const isNested = parentId != null; const floatingProps = $derived({ - id: floatingId, + id: context.floatingId, ...(ariaRole && { role: ariaRole }), }); @@ -75,13 +74,13 @@ function useRole( if (ariaRole === "tooltip" || role === "label") { return { [`aria-${role === "label" ? "labelledby" : "describedby"}` as const]: - open ? floatingId : undefined, + context.open ? context.floatingId : undefined, }; } return { - "aria-expanded": open ? "true" : "false", + "aria-expanded": context.open ? "true" : "false", "aria-haspopup": ariaRole === "alertdialog" ? "dialog" : ariaRole, - "aria-controls": open ? floatingId : undefined, + "aria-controls": context.open ? context.floatingId : undefined, ...(ariaRole === "listbox" && { role: "combobox" }), ...(ariaRole === "menu" && { id: referenceId }), ...(ariaRole === "menu" && isNested && { role: "menuitem" }), diff --git a/packages/floating-ui-svelte/src/index.ts b/packages/floating-ui-svelte/src/index.ts index 5c8075b2..fec9bd7c 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -9,7 +9,7 @@ export { default as FloatingArrow } from "./components/floating-arrow.svelte"; */ export * from "./hooks/use-click.svelte.js"; export * from "./hooks/use-dismiss.svelte.js"; -export * from "./hooks/use-position.svelte.js"; +export * from "./hooks/use-floating.svelte.js"; export * from "./hooks/use-focus.svelte.js"; export * from "./hooks/use-hover.svelte.js"; export * from "./hooks/use-id.js"; diff --git a/packages/floating-ui-svelte/src/types.ts b/packages/floating-ui-svelte/src/types.ts index 03aecdb4..abba0e64 100644 --- a/packages/floating-ui-svelte/src/types.ts +++ b/packages/floating-ui-svelte/src/types.ts @@ -37,12 +37,12 @@ interface FloatingElements { floating?: FloatingElement | null; } -interface ExtendedElements extends Required { +interface ExtendedElements extends Required { /** * Some hooks require the reference element to be a DOM element, * not a VirtualElement. */ - domReference: Element | null; + domReference: NarrowedElement | null; } interface ContextData { @@ -59,7 +59,8 @@ interface ContextData { [key: string]: unknown; } -interface FloatingContext extends Omit { +interface FloatingContext + extends Omit { /** * Represents the open/close state of the floating element. */ @@ -88,12 +89,12 @@ interface FloatingContext extends Omit { /** * The id for the floating element */ - floatingId: string; + floatingId: string | undefined; /** * Object containing the floating and reference elements. */ - elements: ExtendedElements; + elements: ExtendedElements; } interface FloatingNodeType { diff --git a/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts index 70175865..725daf4b 100644 --- a/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts @@ -316,7 +316,7 @@ describe("useFloating", () => { "can be set", withRunes(() => { const floating = useFloating({ open: true }); - expect(floating.open).toBe(true); + expect(floating.context.open).toBe(true); }), ); it( @@ -330,14 +330,14 @@ describe("useFloating", () => { "defaults to true", withRunes(() => { const floating = useFloating(); - expect(floating.open).toBe(true); + expect(floating.context.open).toBe(true); }), ); it( "is of type boolean", withRunes(() => { const floating = useFloating(); - expectTypeOf(floating.open).toMatchTypeOf(); + expectTypeOf(floating.context.open).toMatchTypeOf(); }), ); it( @@ -352,12 +352,12 @@ describe("useFloating", () => { }, }); - expect(floating.open).toBe(open); + expect(floating.context.open).toBe(open); open = true; await vi.waitFor(() => { - expect(floating.open).toBe(open); + expect(floating.context.open).toBe(open); }); }), ); @@ -409,8 +409,8 @@ describe("useFloating", () => { expect(whileElementsMounted).toHaveBeenCalled(); }); - floating.elements.reference = undefined; - floating.elements.floating = undefined; + floating.elements.reference = null; + floating.elements.floating = null; await vi.waitFor(() => { expect(cleanup).toHaveBeenCalled(); diff --git a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-click.svelte b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-click.svelte index 7174f591..3281adbc 100644 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-click.svelte +++ b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-click.svelte @@ -1,53 +1,52 @@ + this={element} + data-testid="reference" + bind:this={floating.elements.reference} + {...interactions.getReferenceProps()}> {#if open} -
-{/if} \ No newline at end of file +
+
+{/if} diff --git a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-dismiss.svelte b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-dismiss.svelte index a03f3759..08917836 100644 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-dismiss.svelte +++ b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-dismiss.svelte @@ -1,43 +1,42 @@ + data-testid="reference" + bind:this={floating.elements.reference} + {...interactions.getReferenceProps()}> {#if open} -
-{/if} \ No newline at end of file +
+
+{/if} diff --git a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-focus.svelte b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-focus.svelte index 0a531e0c..3311ef88 100644 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-focus.svelte +++ b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-focus.svelte @@ -1,43 +1,42 @@ + data-testid="reference" + bind:this={floating.elements.reference} + {...interactions.getReferenceProps()}>button {#if open} -
-{/if} \ No newline at end of file +
+
+{/if} diff --git a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte index 341e4e06..521b6407 100644 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte +++ b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte @@ -1,50 +1,49 @@ {#if showReference} - + {/if} {#if open} -
-{/if} \ No newline at end of file +
+
+{/if} diff --git a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-role.svelte b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-role.svelte index a0c879b5..985d8449 100644 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-role.svelte +++ b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-role.svelte @@ -1,46 +1,49 @@ + data-testid="reference" + bind:this={floating.elements.reference} + {...interactions.getReferenceProps()}> + button + {#if open} -
- {#each [1, 2, 3] as i} -
- {/each} -
-{/if} \ No newline at end of file +
+ {#each [1, 2, 3] as i} +
+
+ {/each} +
+{/if} From 35203e95cae30682c476ea9b37584a905a56f262 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Thu, 9 Jan 2025 21:26:42 -0500 Subject: [PATCH 04/87] whew --- .../{ => floating-tree}/floating-arrow.svelte | 18 +- .../floating-tree/floating-tree.svelte | 6 +- .../components/floating-tree/hooks.svelte.ts | 12 +- .../src/hooks/use-click.svelte.ts | 2 +- .../src/hooks/use-dismiss.svelte.ts | 2 +- .../hooks/use-floating-root-context.svelte.ts | 171 +++----- .../src/hooks/use-floating.svelte.ts | 412 ++++++++++-------- .../src/hooks/use-focus.svelte.ts | 3 +- .../src/hooks/use-position.svelte.ts | 208 ++++----- packages/floating-ui-svelte/src/types.ts | 98 +++-- 10 files changed, 453 insertions(+), 479 deletions(-) rename packages/floating-ui-svelte/src/components/{ => floating-tree}/floating-arrow.svelte (91%) diff --git a/packages/floating-ui-svelte/src/components/floating-arrow.svelte b/packages/floating-ui-svelte/src/components/floating-tree/floating-arrow.svelte similarity index 91% rename from packages/floating-ui-svelte/src/components/floating-arrow.svelte rename to packages/floating-ui-svelte/src/components/floating-tree/floating-arrow.svelte index 6fe9e830..414bc5e9 100644 --- a/packages/floating-ui-svelte/src/components/floating-arrow.svelte +++ b/packages/floating-ui-svelte/src/components/floating-tree/floating-arrow.svelte @@ -1,6 +1,6 @@ @@ -55,7 +49,12 @@ import type { Alignment, Side } from "@floating-ui/dom"; import { platform } from "@floating-ui/dom"; import { useId } from "../../hooks/use-id.js"; - import { styleObjectToString } from "../../internal/style-object-to-string.js"; + import { + styleObjectToString, + styleStringToObject, + } from "../../internal/style-object-to-string.js"; + import parse from "style-to-object"; + import { watch } from "../../internal/watch.svelte.js"; let { ref = $bindable(null), @@ -68,15 +67,44 @@ staticOffset, stroke, d, - // --- - transform, - fill, - // --- + style: styleProp = "", ...rest }: FloatingArrowProps = $props(); + const { transform, ...restStyle } = $derived( + styleStringToObject(styleProp) + ); + const clipPathId = useId(); + let isRTL = $state(false); + + // https://github.com/floating-ui/floating-ui/issues/2932 + watch( + () => context.elements.floating, + (floatingEl) => { + if (!floatingEl) return; + if (getComputedStyle(floatingEl).direction === "rtl") { + isRTL = true; + } + } + ); + + const [side, alignment] = $derived( + context.placement.split("-") as [Side, Alignment] + ); + const isVerticalSide = $derived(side === "top" || side === "bottom"); + + const computedStaticOffset = $derived.by(() => { + if ( + (isVerticalSide && context.middlewareData.shift?.x) || + (!isVerticalSide && context.middlewareData.shift?.y) + ) { + return null; + } + return staticOffset; + }); + // Strokes must be double the border width, this ensures the stroke's width // works as you'd expect. const computedStrokeWidth = $derived(strokeWidth * 2); @@ -85,33 +113,27 @@ const svgX = $derived((width / 2) * (tipRadius / -8 + 1)); const svgY = $derived(((height / 2) * tipRadius) / 4); - const [side, alignment] = $derived( - context.placement.split("-") as [Side, Alignment] - ); - const isRTL = $derived( - context.elements.floating && platform.isRTL(context.elements.floating) - ); const isCustomShape = $derived(!!d); - const isVerticalSide = $derived(side === "top" || side === "bottom"); const yOffsetProp = $derived( - staticOffset && alignment === "end" ? "bottom" : "top" + computedStaticOffset && alignment === "end" ? "bottom" : "top" ); const xOffsetProp = $derived.by(() => { - if (!staticOffset) { - return "left"; - } - if (isRTL) { - return alignment === "end" ? "right" : "left"; + if (computedStaticOffset && isRTL) { + return alignment === "end" ? "left" : "right"; } return alignment === "end" ? "right" : "left"; }); const arrowX = $derived( - arrow?.x != null ? staticOffset || `${arrow.x}px` : "" + context.middlewareData.arrow?.x != null + ? staticOffset || `${context.middlewareData.arrow.x}px` + : "" ); const arrowY = $derived( - arrow?.y != null ? staticOffset || `${arrow.y}px` : "" + context.middlewareData.arrow?.y != null + ? staticOffset || `${context.middlewareData.arrow.y}px` + : "" ); const dValue = $derived( @@ -120,61 +142,61 @@ ); const rotation = $derived.by(() => { - switch (side) { - case "top": - return isCustomShape ? "rotate(180deg)" : ""; - case "left": - return isCustomShape ? "rotate(90deg)" : "rotate(-90deg)"; - case "bottom": - return isCustomShape ? "" : "rotate(180deg)"; - case "right": - return isCustomShape ? "rotate(-90deg)" : "rotate(90deg)"; - } + return { + top: isCustomShape ? "rotate(180deg)" : "", + left: isCustomShape ? "rotate(90deg)" : "rotate(-90deg)", + bottom: isCustomShape ? "" : "rotate(180deg)", + right: isCustomShape ? "rotate(-90deg)" : "rotate(90deg)", + }[side]; }); - width ? height : width}`} - aria-hidden="true" - style={styleObjectToString({ - position: "absolute", - "pointer-events": "none", - [xOffsetProp]: `${arrowX}`, - [yOffsetProp]: `${arrowY}`, - [side]: - isVerticalSide || isCustomShape - ? "100%" - : `calc(100% - ${computedStrokeWidth / 2}px)`, - transform: `${rotation} ${transform ?? ""}`, - fill, - })} - data-testid="floating-arrow" - {...rest}> - {#if computedStrokeWidth > 0} - - - {/if} - + + {/if} + - - - - - - + + + + + + +{/if} diff --git a/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte index f389fd07..85a80a10 100644 --- a/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte +++ b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte @@ -1,10 +1,6 @@ - - -{#if context.elements.floating} +{#if context.floating} ; + + /** + * If already open from another event such as the `useHover()` Hook, + * determines whether to keep the floating element open when clicking the + * reference element for the first time. + * @default true + */ + stickIfOpen?: boolean; } function isButtonTarget(event: KeyboardEvent) { @@ -53,15 +62,20 @@ function isSpaceIgnored(element: ReferenceType | null) { return isTypeableElement(element); } -class ClickInteraction { +const pointerTypes = ["mouse", "pen", "touch"] as const; + +type PointerType = (typeof pointerTypes)[number]; + +class ClickInteraction implements ElementProps { #enabled = $derived.by(() => extract(this.options?.enabled, true)); #eventOption = $derived.by(() => extract(this.options?.event, "click")); #toggle = $derived.by(() => extract(this.options?.toggle, true)); #ignoreMouse = $derived.by(() => extract(this.options?.ignoreMouse, false)); + #stickIfOpen = $derived.by(() => extract(this.options?.stickIfOpen, true)); #keyboardHandlers = $derived.by(() => extract(this.options?.keyboardHandlers, true), ); - #pointerType: PointerEvent["pointerType"] | undefined = undefined; + #pointerType: PointerType | undefined = undefined; #didKeyDown = false; constructor( @@ -69,18 +83,24 @@ class ClickInteraction { private readonly options: UseClickOptions = {}, ) {} + #onpointerdown = (event: PointerEvent) => { + if (!isPointerType(event.pointerType)) return; + this.#pointerType = event.pointerType; + }; + #onmousedown = (event: MouseEvent) => { + // Ignore all buttons except for the "main" button. + // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button if (event.button !== 0) return; + if (this.#eventOption === "click") return; if (isMouseLikePointerType(this.#pointerType, true) && this.#ignoreMouse) { return; } - if (this.#eventOption === "click") return; - if ( this.context.open && this.#toggle && - (this.context.data.openEvent + (this.context.data.openEvent && this.#stickIfOpen ? this.context.data.openEvent.type === "mousedown" : true) ) { @@ -92,10 +112,6 @@ class ClickInteraction { } }; - #onpointerdown = (event: PointerEvent) => { - this.#pointerType = event.pointerType; - }; - #onclick = (event: MouseEvent) => { if (this.#eventOption === "mousedown" && this.#pointerType) { this.#pointerType = undefined; @@ -109,7 +125,7 @@ class ClickInteraction { if ( this.context.open && this.#toggle && - (this.context.data.openEvent + (this.context.data.openEvent && this.#stickIfOpen ? this.context.data.openEvent.type === "click" : true) ) { @@ -165,7 +181,11 @@ class ClickInteraction { } }; - readonly reference = $derived.by(() => { + get reference() { + return this.#reference; + } + + #reference = $derived.by(() => { if (!this.#enabled) return {}; return { onpointerdown: this.#onpointerdown, @@ -175,10 +195,6 @@ class ClickInteraction { onkeyup: this.#onkeyup, }; }); - - get enabled() { - return this.#enabled; - } } function useClick(context: FloatingContext, options: UseClickOptions = {}) { diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index 8cc3c854..b1812c1d 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -21,6 +21,8 @@ import { getChildren } from "../internal/get-children.js"; import { on } from "svelte/events"; import { executeCallbacks } from "../internal/execute-callbacks.js"; import { extract } from "../internal/extract.js"; +import { watch } from "../internal/watch.svelte.js"; +import type { ElementProps } from "./use-interactions.svelte.js"; const bubbleHandlerKeys = { pointerdown: "onpointerdown", @@ -119,7 +121,7 @@ interface UseDismissOptions { >; } -class DismissInteraction { +class DismissInteraction implements ElementProps { #enabled = $derived.by(() => extract(this.options.enabled, true)); #escapeKey = $derived.by(() => extract(this.options.escapeKey, true)); #unstable_outsidePress = $derived.by(() => this.options.outsidePress ?? true); @@ -137,7 +139,6 @@ class DismissInteraction { ); #bubbles = $derived.by(() => extract(this.options.bubbles)); #capture = $derived.by(() => extract(this.options.capture)); - #outsidePressFn = $derived.by(() => typeof this.#unstable_outsidePress === "function" ? this.#unstable_outsidePress @@ -264,8 +265,7 @@ class DismissInteraction { }; }); - $effect(() => { - [this.#outsidePress, this.#outsidePressEvent]; + watch([() => this.#outsidePress, () => this.#outsidePressEvent], () => { this.#insideTree = false; }); } @@ -454,7 +454,7 @@ class DismissInteraction { getTarget(event)?.addEventListener(this.#outsidePressEvent, callback); } - readonly reference = $derived.by(() => { + #reference = $derived.by(() => { if (!this.#enabled) return {}; return { onkeydown: this.#closeOnEscapeKeyDown, @@ -471,7 +471,7 @@ class DismissInteraction { }; }); - readonly floating = $derived.by(() => { + #floating = $derived.by(() => { if (!this.#enabled) return {}; return { onkeydown: this.#closeOnEscapeKeyDown, @@ -487,8 +487,12 @@ class DismissInteraction { }; }); - get enabled() { - return this.#enabled; + get reference() { + return this.#reference; + } + + get floating() { + return this.#floating; } } diff --git a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts index 0e9c94ed..e664d2fe 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts @@ -71,11 +71,6 @@ class FloatingRootContextOptions { this.reference.current = this.referenceProp; this.floating.current = this.floatingProp; - $effect.pre(() => { - console.log("REFERENCE IN FLOATING ROOT", this.reference.current); - console.log("REFERENCE PROP IN FLOATING ROOT", this.referenceProp); - }); - $effect.pre(() => { this.reference.current = this.referenceProp; }); diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index e8950119..ff519c2f 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -96,6 +96,10 @@ interface UseFloatingOptions { nodeId?: MaybeGetter; } +/** + * Reactive options for the `useFloating` hook. + */ +// This enables us to not have to pass around a bunch of getters and setters internally. class FloatingOptions { open: ReadableBox; placement: ReadableBox; @@ -155,21 +159,16 @@ class FloatingOptions { this.reference.current = this.referenceProp; this.floating.current = this.floatingProp; - $effect(() => { - console.log("REFERENCE IN FLOATING OPTIONS", this.reference.current); - console.log("REFERENCE PROP IN FLOATING OPTIONS", this.referenceProp); - }); - - $effect(() => { - this.floating.current = this.floatingProp; - }); - - $effect(() => { - this.reference.current = this.referenceProp; + $effect.pre(() => { + if (this.floatingProp) { + this.floating.current = this.floatingProp; + } }); - $effect(() => { - console.log("OPEN STATE:", this.open.current); + $effect.pre(() => { + if (this.referenceProp) { + this.reference.current = this.referenceProp; + } }); } } @@ -288,11 +287,10 @@ class FloatingState { #rootContext: FloatingRootContext; #position: PositionState; #positionReference = $state(null); - #_domReference = $state | null>(null); #derivedDomReference = $derived.by( () => (this.#rootContext.domReference || - this.#_domReference) as NarrowedElement, + this.options.reference.current) as NarrowedElement, ); #tree: FloatingTreeType | null; context: FloatingContext; @@ -302,6 +300,7 @@ class FloatingState { open: () => options.open.current ?? true, reference: () => options.reference.current, floating: () => options.floating.current, + onOpenChange: options.onOpenChange, }); this.#rootContext = @@ -342,7 +341,6 @@ class FloatingState { } set reference(node: RT | null) { - console.log("setting reference!", node); if (isElement(node) || node === null) { this.options.reference.current = node; } diff --git a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts index 778dea2c..1f6a9f81 100644 --- a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts @@ -14,6 +14,8 @@ import type { FloatingContext } from "./use-floating.svelte.js"; import { on } from "svelte/events"; import { executeCallbacks } from "../internal/execute-callbacks.js"; import { extract } from "../internal/extract.js"; +import { watch, watchOnce } from "../internal/watch.svelte.js"; +import type { ElementProps } from "./use-interactions.svelte.js"; interface UseFocusOptions { /** @@ -30,7 +32,7 @@ interface UseFocusOptions { visibleOnly?: MaybeGetter; } -class FocusInteraction { +class FocusInteraction implements ElementProps { #enabled = $derived.by(() => extract(this.options.enabled, true)); #visibleOnly = $derived.by(() => extract(this.options.visibleOnly, true)); #blockFocus = false; @@ -41,53 +43,58 @@ class FocusInteraction { private readonly context: FloatingContext, private readonly options: UseFocusOptions = {}, ) { - $effect(() => { - if (!this.#enabled) return; - - const win = getWindow(this.context.domReference); - - // If the domReference was focused and the user left the tab/window, and the - // floating element was not open, the focus should be blocked when they - // return to the tab/window. - const onBlur = () => { - if ( - !open && - isHTMLElement(context.domReference) && - context.domReference === - activeElement(getDocument(context.domReference)) - ) { - this.#blockFocus = true; - } - }; - - const onKeyDown = () => { - this.#keyboardModality = true; - }; - - return executeCallbacks( - on(win, "blur", onBlur), - on(win, "keydown", onKeyDown, { capture: true }), - ); - }); - - $effect(() => { - if (!this.#enabled) return; - - const onOpenChange = ({ reason }: { reason: OpenChangeReason }) => { - if (reason === "reference-press" || reason === "escape-key") { - this.#blockFocus = true; - } - }; - - this.context.events.on("openchange", onOpenChange); - return () => { - this.context.events.off("openchange", onOpenChange); - }; - }); + watch( + [() => this.#enabled, () => this.context.domReference], + ([enabled, domReference]) => { + if (!enabled) return; + + const win = getWindow(domReference); + + // If the domReference was focused and the user left the tab/window, and the + // floating element was not open, the focus should be blocked when they + // return to the tab/window. + const onBlur = () => { + if ( + !context.open && + isHTMLElement(domReference) && + domReference === activeElement(getDocument(domReference)) + ) { + this.#blockFocus = true; + } + }; + + const onKeyDown = () => { + this.#keyboardModality = true; + }; + + return executeCallbacks( + on(win, "blur", onBlur), + on(win, "keydown", onKeyDown, { capture: true }), + ); + }, + ); + + watch( + () => this.#enabled, + (enabled) => { + if (!enabled) return; + + const onOpenChange = ({ reason }: { reason: OpenChangeReason }) => { + if (reason === "reference-press" || reason === "escape-key") { + this.#blockFocus = true; + } + }; + + this.context.events.on("openchange", onOpenChange); + return () => { + this.context.events.off("openchange", onOpenChange); + }; + }, + ); $effect(() => { return () => { - clearTimeout(this.#timeout); + window.clearTimeout(this.#timeout); }; }); } @@ -167,7 +174,7 @@ class FocusInteraction { }); }; - readonly reference = $derived.by(() => { + #reference = $derived.by(() => { if (!this.#enabled) return {}; return { onpointerdown: this.#onpointerdown, @@ -177,8 +184,8 @@ class FocusInteraction { }; }); - get enabled() { - return this.#enabled; + get reference() { + return this.#reference; } } diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index 7b9650cb..4e372282 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -4,6 +4,8 @@ import { createAttribute, getDocument, isMouseLikePointerType, + isPointerType, + type PointerType, } from "../internal/dom.js"; import { noop } from "../internal/noop.js"; import type { FloatingTreeType, OpenChangeReason } from "../types.js"; @@ -18,6 +20,8 @@ import { import { on } from "svelte/events"; import { executeCallbacks } from "../internal/execute-callbacks.js"; import { snapshotFloatingContext } from "../internal/snapshot.svelte.js"; +import { watch } from "../internal/watch.svelte.js"; +import type { ElementProps } from "./use-interactions.svelte.js"; interface DelayOptions { /** @@ -89,7 +93,7 @@ const safePolygonIdentifier = createAttribute("safe-polygon"); function getDelay( value: UseHoverOptions["delay"], prop: "open" | "close", - pointerType?: PointerEvent["pointerType"], + pointerType?: PointerType, ) { if (pointerType && !isMouseLikePointerType(pointerType)) { return 0; @@ -102,16 +106,16 @@ function getDelay( return value?.[prop]; } -class HoverInteraction { +class HoverInteraction implements ElementProps { #enabled = $derived.by(() => this.options.enabled ?? true); #mouseOnly = $derived.by(() => this.options.mouseOnly ?? false); #delay = $derived.by(() => this.options.delay ?? 0); #restMs = $derived.by(() => this.options.restMs ?? 0); #move = $derived.by(() => this.options.move ?? true); - #handleClose = $derived.by(() => this.options.handleClose ?? null); + #handleClose = $state(null); #tree: FloatingTreeType | null = null; #parentId: string | null = null; - #pointerType: string | undefined = undefined; + #pointerType: PointerType | undefined = undefined; #timeout = -1; #handler: ((event: MouseEvent) => void) | undefined = undefined; #restTimeout = -1; @@ -133,40 +137,61 @@ class HoverInteraction { private readonly context: FloatingContext, private readonly options: UseHoverOptions = {}, ) { - this.#tree = useFloatingTree(); - this.#parentId = useFloatingParentNodeId(); - - $effect(() => { - if (!this.#enabled) return; + this.#handleClose = options.handleClose; - const onOpenChange = ({ open }: { open: boolean }) => { - if (open) return; - window.clearTimeout(this.#timeout); - window.clearTimeout(this.#restTimeout); - this.#blockMouseMove = true; - this.#restTimeoutPending = true; - }; + $effect.pre(() => { + this.#handleClose = options.handleClose; + }); - this.context.events.on("openchange", onOpenChange); + this.#tree = useFloatingTree(); + this.#parentId = useFloatingParentNodeId(); - return () => { - this.context.events.off("openchange", onOpenChange); - }; - }); + watch( + [() => this.#enabled, () => this.context.events], + ([enabled, events]) => { + if (!enabled) return; - $effect(() => { - if (!this.#enabled || !this.#handleClose || !this.context.open) return; + const onOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + window.clearTimeout(this.#timeout); + window.clearTimeout(this.#restTimeout); + this.#blockMouseMove = true; + this.#restTimeoutPending = false; + } + }; - const onLeave = (event: MouseEvent) => { - if (!this.#isHoverOpen) return; - this.context.onOpenChange(false, event, "hover"); - }; + events.on("openchange", onOpenChange); - const html = getDocument(this.context.floating).documentElement; + return () => { + events.off("openchange", onOpenChange); + }; + }, + ); + + watch( + [ + () => this.#enabled, + () => this.#handleClose, + () => this.context.open, + () => this.context.floating, + () => this.#isHoverOpen, + ], + ([enabled, handleClose, open, floating, isHoverOpen]) => { + if (!enabled || !handleClose || !open) return; + + const onLeave = (event: MouseEvent) => { + if (!this.#isHoverOpen) return; + this.context.onOpenChange(false, event, "hover"); + }; - return on(html, "mouseleave", onLeave); - }); + const html = getDocument(floating).documentElement; + return on(html, "mouseleave", onLeave); + }, + ); + // Registering the mouse events on the reference directly to bypass Svelte's + // delegation system. If the cursor was on a disabled element and then entered + // the reference (no gap), `mouseenter` doesn't fire in the delegation system. $effect(() => { if (!this.#enabled) return; @@ -185,12 +210,12 @@ class HoverInteraction { if (openDelay) { this.#timeout = window.setTimeout(() => { - if (!this.context.open) { - this.context.onOpenChange(true, event, "hover"); + if (!context.open) { + context.onOpenChange(true, event, "hover"); } }, openDelay); - } else if (!this.context.open) { - this.context.onOpenChange(true, event, "hover"); + } else if (!context.open) { + context.onOpenChange(true, event, "hover"); } }; @@ -198,18 +223,19 @@ class HoverInteraction { if (this.#isClickLikeOpenEvent) return; this.#unbindMouseMove(); - const doc = getDocument(this.context.floating); + + const doc = getDocument(context.floating); window.clearTimeout(this.#restTimeout); this.#restTimeoutPending = false; - if (this.#handleClose && this.context.data.floatingContext) { - // prevent clearing `onScrollMouseLeave` timeout - if (!this.context.open) { + if (this.#handleClose && context.data.floatingContext) { + // Prevent clearing `onScrollMouseLeave` timeout. + if (!context.open) { window.clearTimeout(this.#timeout); } this.#handler = this.#handleClose({ - ...snapshotFloatingContext(this.context).current, + ...snapshotFloatingContext(context).current, tree: this.#tree, x: event.clientX, y: event.clientY, @@ -224,23 +250,20 @@ class HoverInteraction { const handler = this.#handler; - const removeListener = on(doc, "mousemove", handler); - + doc.addEventListener("mousemove", handler); this.#unbindMouseMove = () => { - removeListener(); + doc.removeEventListener("mousemove", handler); }; return; } + // Allow interactivity without `safePolygon` on touch devices. With a // pointer, a short close delay is an alternative, so it should work // consistently. const shouldClose = this.#pointerType === "touch" - ? !contains( - this.context.floating, - event.relatedTarget as Element | null, - ) + ? !contains(context.floating, event.relatedTarget as Element | null) : true; if (shouldClose) { this.#closeWithDelay(event); @@ -251,12 +274,11 @@ class HoverInteraction { // did not move. // https://github.com/floating-ui/floating-ui/discussions/1692 const onScrollMouseLeave = (event: MouseEvent) => { - if (!this.#isClickLikeOpenEvent || !this.context.data.floatingContext) { - return; - } + if (this.#isClickLikeOpenEvent) return; + if (!context.data.floatingContext) return; this.#handleClose?.({ - ...snapshotFloatingContext(this.context).current, + ...snapshotFloatingContext(context.data.floatingContext).current, tree: this.#tree, x: event.clientX, y: event.clientY, @@ -270,29 +292,24 @@ class HoverInteraction { })(event); }; - if (isElement(this.context.domReference)) { - const domRef = this.context.domReference as HTMLElement; - const listenersToRemove: Array<() => void> = []; - if (this.context.open) { - listenersToRemove.push(on(domRef, "mouseleave", onScrollMouseLeave)); - } - if (this.context.floating) { - listenersToRemove.push( - on(this.context.floating, "mouseleave", onScrollMouseLeave), - ); - } - if (this.#move) { - listenersToRemove.push( - on(domRef, "mousemove", onMouseEnter, { once: true }), - ); - } - listenersToRemove.push( - on(domRef, "mouseenter", onMouseEnter), - on(domRef, "mouseleave", onMouseLeave), - ); - + if (isElement(context.domReference)) { + const ref = context.domReference as unknown as HTMLElement; + context.open && ref.addEventListener("mouseleave", onScrollMouseLeave); + context.floating?.addEventListener("mouseleave", onScrollMouseLeave); + this.#move && + ref.addEventListener("mousemove", onMouseEnter, { once: true }); + ref.addEventListener("mouseenter", onMouseEnter); + ref.addEventListener("mouseleave", onMouseLeave); return () => { - executeCallbacks(...listenersToRemove); + context.open && + ref.removeEventListener("mouseleave", onScrollMouseLeave); + context.floating?.removeEventListener( + "mouseleave", + onScrollMouseLeave, + ); + this.#move && ref.removeEventListener("mousemove", onMouseEnter); + ref.removeEventListener("mouseenter", onMouseEnter); + ref.removeEventListener("mouseleave", onMouseLeave); }; } }); @@ -301,24 +318,30 @@ class HoverInteraction { // while the floating element is open and has a `handleClose` handler. Also // handles nested floating elements. // https://github.com/floating-ui/floating-ui/issues/1722 + watch( + [ + () => this.#enabled, + () => this.context.open, + () => this.context.floating, + () => this.context.domReference, + () => this.#handleClose, + () => this.#isHoverOpen, + ], + ([enabled, open, floating, domReference, handleClose, treeNodes]) => { + if (!enabled) return; + if ( + open && + handleClose?.__options.blockPointerEvents && + this.#isHoverOpen + ) { + this.#performedPointerEventsMutation = true; + const floatingEl = floating; + if (!isElement(domReference) || !floatingEl) return; - $effect(() => { - if (!this.#enabled) return; - - if ( - this.context.open && - this.#handleClose?.__options.blockPointerEvents && - this.#isHoverOpen - ) { - this.#performedPointerEventsMutation = true; - const floatingEl = this.context.floating; - if (isElement(this.context.domReference) && floatingEl) { - const body = getDocument(floatingEl).body; + const body = getDocument(floating).body; body.setAttribute(safePolygonIdentifier, ""); - const ref = this.context.domReference as unknown as - | HTMLElement - | SVGSVGElement; + const ref = domReference as unknown as HTMLElement | SVGSVGElement; const parentFloating = this.#tree?.nodes.find( (node) => node.id === this.#parentId, @@ -329,16 +352,17 @@ class HoverInteraction { } body.style.pointerEvents = "none"; - ref.style.pointerEvents = ""; - floatingEl.style.pointerEvents = ""; + ref.style.pointerEvents = "auto"; + floatingEl.style.pointerEvents = "auto"; + return () => { body.style.pointerEvents = ""; ref.style.pointerEvents = ""; floatingEl.style.pointerEvents = ""; }; } - } - }); + }, + ); $effect(() => { if (!this.context.open) { @@ -349,8 +373,7 @@ class HoverInteraction { } }); - $effect(() => { - [this.#enabled, this.context.domReference]; + watch([() => this.#enabled, () => this.context.domReference], () => { return () => { this.#cleanupMouseMoveHandler(); window.clearTimeout(this.#timeout); @@ -393,6 +416,7 @@ class HoverInteraction { }; #setPointerType = (event: PointerEvent) => { + if (!isPointerType(event.pointerType)) return; this.#pointerType = event.pointerType; }; @@ -433,7 +457,7 @@ class HoverInteraction { this.#closeWithDelay(event, false); }; - reference = $derived.by(() => { + #reference = $derived.by(() => { if (!this.#enabled) return {}; return { onpointerdown: this.#setPointerType, @@ -442,7 +466,7 @@ class HoverInteraction { }; }); - floating = $derived.by(() => { + #floating = $derived.by(() => { if (!this.#enabled) return {}; return { onmouseenter: this.#onFloatingMouseEnter, @@ -450,8 +474,12 @@ class HoverInteraction { }; }); - get enabled() { - return this.#enabled; + get reference() { + return this.#reference; + } + + get floating() { + return this.#floating; } } diff --git a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts index 49745074..d2654f3b 100644 --- a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts @@ -44,7 +44,7 @@ function mergeProps( } return { - ...(elementKey === "floating" && { tabIndex: -1 }), + ...(elementKey === "floating" && { tabindex: -1 }), ...domUserProps, ...propsList .map((value) => { diff --git a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts index 25f1602c..c9b7c394 100644 --- a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts @@ -160,9 +160,9 @@ class PositionState { ) { this.data.strategy = this.options.strategy.current; this.data.placement = this.options.placement.current; + $effect.pre(() => { if (this.rootContext.open || !this.data.isPositioned) return; - this.data.isPositioned = false; }); diff --git a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts index 41540188..95ba7068 100644 --- a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts @@ -42,7 +42,7 @@ const componentRoleToAriaRoleMap = new Map< ["label", false], ]); -class RoleInteraction { +class RoleInteraction implements ElementProps { #enabled = $derived.by(() => extract(this.options.enabled, true)); #role = $derived.by(() => extract(this.options.role, "dialog")); #ariaRole = $derived( @@ -63,7 +63,7 @@ class RoleInteraction { this.#isNested = this.#parentId != null; } - reference: ElementProps["reference"] = $derived.by(() => { + #reference: ElementProps["reference"] = $derived.by(() => { if (!this.#enabled) return {}; if (this.#ariaRole === "tooltip" || this.#role === "label") { return { @@ -87,7 +87,7 @@ class RoleInteraction { }; }); - floating: ElementProps["floating"] = $derived.by(() => { + #floating: ElementProps["floating"] = $derived.by(() => { if (!this.#enabled) return {}; const floatingProps = { id: this.context.floatingId, @@ -106,7 +106,7 @@ class RoleInteraction { }; }); - item: ElementProps["item"] = $derived.by(() => { + #item: ElementProps["item"] = $derived.by(() => { return ({ active, selected }: ExtendedUserProps) => { if (!this.#enabled) return {}; const commonProps = { @@ -132,8 +132,16 @@ class RoleInteraction { }; }); - get enabled() { - return this.#enabled; + get reference() { + return this.#reference; + } + + get floating() { + return this.#floating; + } + + get item() { + return this.#item; } } diff --git a/packages/floating-ui-svelte/src/internal/dom.ts b/packages/floating-ui-svelte/src/internal/dom.ts index e1c27a73..40713d96 100644 --- a/packages/floating-ui-svelte/src/internal/dom.ts +++ b/packages/floating-ui-svelte/src/internal/dom.ts @@ -109,6 +109,12 @@ function isMouseLikePointerType( return values.includes(pointerType); } +const pointerTypes = ["mouse", "pen", "touch"] as const; +type PointerType = (typeof pointerTypes)[number]; +function isPointerType(str: string): str is PointerType { + return pointerTypes.includes(str as PointerType); +} + export { getDocument, activeElement, @@ -119,4 +125,7 @@ export { isEventTargetWithin, isRootElement, isMouseLikePointerType, + isPointerType, }; + +export type { PointerType }; diff --git a/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts b/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts index 15831bc9..fdfebe43 100644 --- a/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts +++ b/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts @@ -13,11 +13,9 @@ function snapshotFloatingContext( return { get current(): FloatingContextData { return { - elements: { - domReference: context.elements.domReference, - floating: context.elements.floating, - reference: context.elements.reference, - }, + domReference: context.domReference, + floating: context.floating, + reference: context.reference, x: context.x, y: context.y, placement: context.placement, diff --git a/sites/floating-ui-svelte.vercel.app/package.json b/sites/floating-ui-svelte.vercel.app/package.json index e44685f8..759c1d95 100644 --- a/sites/floating-ui-svelte.vercel.app/package.json +++ b/sites/floating-ui-svelte.vercel.app/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "vite build && pagefind", "build:watch": "vite dev", + "dev": "vite dev", "sync": "svelte-kit sync" }, "devDependencies": { diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/+layout.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/+layout.svelte index cb3338ec..09f3ee97 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/+layout.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/+layout.svelte @@ -1,11 +1,11 @@ @@ -20,8 +20,7 @@ let { children } = $props();
+ data-pagefind-body> {@render children()}
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/popovers/Example.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/popovers/Example.svelte index f0c04315..b1d4ed20 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/popovers/Example.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/popovers/Example.svelte @@ -1,67 +1,73 @@
{#if open}
+ transition:fade={{ duration: 200 }}>

- You can press the esc key or click outside to + You can press the esc key or click + outside to *dismiss* this floating element.

- +
{/if}
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/+page.svelte index bcd9ad4e..8c06a0e3 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/+page.svelte @@ -1,8 +1,8 @@
@@ -10,8 +10,9 @@ import exampleCode from "./Example.svelte?raw";

Tooltips

- A tooltip is a floating element that displays information related to a button or anchor - element when it receives keyboard focus or the mouse hovers over it. + A tooltip is a floating element that displays information related to + a button or anchor element when it receives keyboard focus or the + mouse hovers over it.

@@ -20,21 +21,25 @@ import exampleCode from "./Example.svelte?raw";

An accessible tooltip component has the following qualities:

  • - Dynamic anchor positioning: The tooltip is positioned next to - its reference element, and remains anchored to it while avoiding collisions. + Dynamic anchor positioning: The + tooltip is positioned next to its reference element, and remains + anchored to it while avoiding collisions.
  • - Events: When the mouse hovers over the reference element, or - when the reference element receives keyboard focus, the tooltip opens. When the mouse + Events: When the mouse hovers + over the reference element, or when the reference element + receives keyboard focus, the tooltip opens. When the mouse leaves, or the reference is blurred, the tooltip closes.
  • - Dismissal: When the user presses the + Dismissal: When the user presses + the esc key while the tooltip is open, it closes.
  • - Role: The elements are given relevant role and ARIA - attributes to be accessible to screen readers. + Role: The elements are given + relevant role and ARIA attributes to be accessible to screen + readers.
@@ -43,7 +48,9 @@ import exampleCode from "./Example.svelte?raw";

Example

{#snippet preview()}{/snippet} - {#snippet code()}{/snippet} + {#snippet code()}{/snippet} @@ -51,34 +58,40 @@ import exampleCode from "./Example.svelte?raw";

Open State

- open determines whether or not the tooltip is currently open on the screen. - It is used for conditional rendering. + open determines whether or not the tooltip + is currently open on the screen. It is used for conditional rendering.

useFloating Hook

- The useFloating hook provides positioning and context - for our tooltip. We need to pass it some information: + The useFloating hook provides + positioning and context for our tooltip. We need to pass it some information:

- +
  • - open: The open state from our useState() + open: The open state from our + useState() Hook above.
  • - onOpenChange: A callback function that will be called when the - tooltip is opened or closed. We’ll use this to update our open state. + onOpenChange: A callback function that + will be called when the tooltip is opened or closed. We’ll use + this to update our open state.
  • - middleware: Import and pass middleware to the array that ensure - the tooltip remains on the screen, no matter where it ends up being positioned. + middleware: Import and pass middleware + to the array that ensure the tooltip remains on the screen, no + matter where it ends up being positioned.
  • - whileElementsMounted: Ensure the tooltip remains anchored to the - reference element by updating the position when necessary, only while both the reference and + whileElementsMounted: Ensure the + tooltip remains anchored to the reference element by updating + the position when necessary, only while both the reference and floating elements are mounted for performance.
@@ -87,10 +100,13 @@ import exampleCode from "./Example.svelte?raw";

Interaction Hooks

- The useInteractions hooks returns an object - containing keys of props that enable the tooltip to be opened, closed, or accessible to screen - readers. Using the - context that was returned from the Hook, call the interaction Hooks. + The useInteractions + hooks returns an object containing keys of props that enable the + tooltip to be opened, closed, or accessible to screen readers. Using + the + context that was returned from the Hook, call + the interaction Hooks.

+ lang="ts" />
  • - useRole(): adds the correct ARIA attributes for a + useRole(): adds the correct ARIA + attributes for a tooltip to the tooltip and reference elements.
  • - useHover(): adds the ability to toggle the tooltip open or closed - when the reference element is hovered over. The move option is set - to false so that + useHover(): adds the ability to toggle + the tooltip open or closed when the reference element is hovered + over. The move option is set to false + so that mousemove events are ignored.
  • - useDismiss(): adds the ability to dismiss the tooltip when the - user presses the esc key. + useDismiss(): adds the ability to + dismiss the tooltip when the user presses the + esc key.
  • - COMING SOON: useFocus(): adds the ability to toggle the tooltip - open or closed when the reference element is focused. + COMING SOON: useFocus(): adds the + ability to toggle the tooltip open or closed when the reference + element is focused.

Rendering

-

Now we have all the variables and Hooks set up, we can render out our elements.

+

+ Now we have all the variables and Hooks set up, we can render out + our elements. +

{/if} - `} - /> + `} />

{`{...getReferenceProps()}`} and - {`{...getFloatingProps()}`} spreads the props from the interaction - Hooks onto the relevant elements. They contain props like - onMouseEnter, aria-describedby, etc. + {`{...getFloatingProps()}`} spreads the + props from the interaction Hooks onto the relevant elements. They + contain props like + onMouseEnter, + aria-describedby, etc.

diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/Example.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/Example.svelte index bee630a9..d0d50c59 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/Example.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/Example.svelte @@ -1,67 +1,68 @@
{#if open}
+ transition:fade={{ duration: 200 }}>

- A floating element is one that floats on top of the UI without disrupting the - flow, like this one! + A floating element is one that floats on top of + the UI without disrupting the flow, like this one!

- +
{/if}
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/+error.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/+error.svelte index 6c941507..763baa04 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/+error.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/+error.svelte @@ -1,9 +1,9 @@ diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/+layout.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/+layout.svelte index 464a5011..e058899a 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/+layout.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/+layout.svelte @@ -1,37 +1,39 @@ diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/+layout.ts b/sites/floating-ui-svelte.vercel.app/src/routes/+layout.ts index 237d97c1..e69de29b 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/+layout.ts +++ b/sites/floating-ui-svelte.vercel.app/src/routes/+layout.ts @@ -1,21 +0,0 @@ -import { browser } from "$app/environment"; -import getLatestVersion from "latest-version"; -import type { Pagefind } from "vite-plugin-pagefind/types"; - -export async function load() { - const version = await getLatestVersion("@skeletonlabs/floating-ui-svelte"); - if (browser) { - // @ts-expect-error - Dynamic import - const pagefind: Pagefind = await import("/pagefind/pagefind.js"); - await pagefind.init(); - return { - version, - pagefind, - }; - } - return { - version, - }; -} - -export const prerender = true; From 1553c67790f5dd2de16b473d005844e7654a7afe Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 11 Jan 2025 20:21:46 -0500 Subject: [PATCH 14/87] lets goooo --- .../floating-tree/floating-tree.svelte | 20 ++--- .../src/hooks/use-dismiss.svelte.ts | 63 +++++++------- .../floating-ui-svelte/src/internal/dom.ts | 8 +- pnpm-lock.yaml | 87 +++---------------- .../package.json | 2 +- .../(inner)/examples/popovers/Example.svelte | 16 ++-- .../(inner)/examples/tooltips/Example.svelte | 2 +- 7 files changed, 62 insertions(+), 136 deletions(-) diff --git a/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte index 85a80a10..fa76374a 100644 --- a/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte +++ b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte @@ -14,20 +14,14 @@ let nodes: FloatingNodeType[] = $state.raw([]); - const addNode = (node: FloatingNodeType) => { - nodes = [...nodes, node]; - }; - - const removeNode = (node: FloatingNodeType) => { - nodes = nodes.filter((n) => n !== node); - }; - - const events = createPubSub(); - FloatingTreeContext.set({ - addNode, - removeNode, - events, + addNode: (node: FloatingNodeType) => { + nodes = [...nodes, node]; + }, + removeNode: (node: FloatingNodeType) => { + nodes = nodes.filter((n) => n !== node); + }, + events: createPubSub(), get nodes() { return nodes; }, diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index b1812c1d..9d3b68cc 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -19,10 +19,10 @@ import type { FloatingTreeType, MaybeGetter } from "../types.js"; import { useFloatingTree } from "../components/floating-tree/hooks.svelte.js"; import { getChildren } from "../internal/get-children.js"; import { on } from "svelte/events"; -import { executeCallbacks } from "../internal/execute-callbacks.js"; import { extract } from "../internal/extract.js"; import { watch } from "../internal/watch.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; +import { untrack } from "svelte"; const bubbleHandlerKeys = { pointerdown: "onpointerdown", @@ -164,10 +164,11 @@ class DismissInteraction implements ElementProps { $effect(() => { if (!this.context.open || !this.#enabled) return; - - this.context.data.__escapeKeyBubbles = this.#bubbleOptions.escapeKey; - this.context.data.__outsidePressBubbles = - this.#bubbleOptions.outsidePress; + untrack(() => { + this.context.data.__escapeKeyBubbles = this.#bubbleOptions.escapeKey; + this.context.data.__outsidePressBubbles = + this.#bubbleOptions.outsidePress; + }); let compositionTimeout = -1; @@ -206,6 +207,7 @@ class DismissInteraction implements ElementProps { this.#captureOptions.escapeKey ? this.#closeOnEscapeKeyDownCapture : this.#closeOnEscapeKeyDown, + { capture: this.#captureOptions.escapeKey }, ), on(doc, "compositionstart", handleCompositionStart), on(doc, "compositionend", handleCompositionEnd), @@ -220,6 +222,7 @@ class DismissInteraction implements ElementProps { this.#captureOptions.outsidePress ? this.#closeOnPressOutsideCapture : this.#closeOnPressOutside, + { capture: this.#captureOptions.outsidePress }, ), ); } @@ -260,7 +263,9 @@ class DismissInteraction implements ElementProps { } return () => { - executeCallbacks(...listenersToRemove); + for (const removeListener of listenersToRemove) { + removeListener(); + } window.clearTimeout(compositionTimeout); }; }); @@ -270,7 +275,7 @@ class DismissInteraction implements ElementProps { }); } - #closeOnEscapeKeyDown(event: KeyboardEvent) { + #closeOnEscapeKeyDown = (event: KeyboardEvent) => { if ( !this.context.open || !this.#enabled || @@ -290,31 +295,27 @@ class DismissInteraction implements ElementProps { event.stopPropagation(); if (children.length > 0) { - let shouldDismiss = true; - - for (const child of children) { - if (child.context?.open && !child.context.data.__escapeKeyBubbles) { - shouldDismiss = false; - break; - } - } + const hasOpenChild = children.some( + (child) => + child.context?.open && !child.context.data.__escapeKeyBubbles, + ); - if (!shouldDismiss) return; + if (hasOpenChild) return; } } this.context.onOpenChange(false, event, "escape-key"); - } + }; - #closeOnEscapeKeyDownCapture(event: KeyboardEvent) { + #closeOnEscapeKeyDownCapture = (event: KeyboardEvent) => { const callback = () => { this.#closeOnEscapeKeyDown(event); getTarget(event)?.removeEventListener("keydown", callback); }; getTarget(event)?.addEventListener("keydown", callback); - } + }; - #closeOnPressOutside(event: MouseEvent) { + #closeOnPressOutside = (event: MouseEvent) => { const localInsideTree = this.#insideTree; this.#insideTree = false; @@ -373,7 +374,7 @@ class DismissInteraction implements ElementProps { } // Check if the click occurred on the scrollbar - if (isHTMLElement(target)) { + if (isHTMLElement(target) && this.context.floating) { const lastTraversableNode = isLastTraversableNode(target); const style = getComputedStyle(target); const scrollRe = /auto|scroll/; @@ -431,28 +432,24 @@ class DismissInteraction implements ElementProps { } if (children.length > 0) { - let shouldDismiss = true; - - for (const child of children) { - if (child.context?.open && !child.context.data.__outsidePressBubbles) { - shouldDismiss = false; - break; - } - } + const hasOpenChild = children.some( + (child) => + child.context?.open && !child.context.data.__outsidePressBubbles, + ); - if (!shouldDismiss) return; + if (hasOpenChild) return; } this.context.onOpenChange(false, event, "outside-press"); - } + }; - #closeOnPressOutsideCapture(event: MouseEvent) { + #closeOnPressOutsideCapture = (event: MouseEvent) => { const callback = () => { this.#closeOnPressOutside(event); getTarget(event)?.removeEventListener(this.#outsidePressEvent, callback); }; getTarget(event)?.addEventListener(this.#outsidePressEvent, callback); - } + }; #reference = $derived.by(() => { if (!this.#enabled) return {}; diff --git a/packages/floating-ui-svelte/src/internal/dom.ts b/packages/floating-ui-svelte/src/internal/dom.ts index 40713d96..4b144a56 100644 --- a/packages/floating-ui-svelte/src/internal/dom.ts +++ b/packages/floating-ui-svelte/src/internal/dom.ts @@ -79,12 +79,12 @@ function getTarget(event: Event) { } function isEventTargetWithin(event: Event, node: Node | null | undefined) { - if (node == null) { - return false; - } + if (node == null) return false; if ("composedPath" in event) { - return event.composedPath().includes(node); + return ( + event.composedPath().includes(node) || node.contains(event.target as Node) + ); } // TS thinks `event` is of type never as it assumes all browsers support composedPath, but browsers without shadow dom don't diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f13b3226..0c087988 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,13 +71,13 @@ importers: version: link:../../packages/floating-ui-svelte '@sveltejs/adapter-vercel': specifier: ^5.5.2 - version: 5.5.2(@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(rollup@4.29.1) + version: 5.5.2(@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(rollup@4.29.1) '@sveltejs/kit': specifier: ^2.15.1 - version: 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + version: 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) '@sveltejs/vite-plugin-svelte': specifier: ^5.0.3 - version: 5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + version: 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) @@ -89,7 +89,7 @@ importers: version: 9.0.0 lucide-svelte: specifier: ^0.469.0 - version: 0.469.0(svelte@5.16.0) + version: 0.469.0(svelte@5.17.3) pagefind: specifier: ^1.3.0 version: 1.3.0 @@ -97,11 +97,11 @@ importers: specifier: ^1.24.4 version: 1.24.4 svelte: - specifier: ^5.16.0 - version: 5.16.0 + specifier: ^5.17.3 + version: 5.17.3 svelte-check: specifier: ^4.1.1 - version: 4.1.1(picomatch@4.0.2)(svelte@5.16.0)(typescript@5.7.2) + version: 4.1.1(picomatch@4.0.2)(svelte@5.17.3)(typescript@5.7.2) tailwindcss: specifier: ^3.4.17 version: 3.4.17 @@ -2018,10 +2018,6 @@ packages: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 - svelte@5.16.0: - resolution: {integrity: sha512-Ygqsiac6UogVED2ruKclU+pOeMThxWtp9LG+li7BXeDKC2paVIsRTMkNmcON4Zejerd1s5sZHWx6ZtU85xklVg==} - engines: {node: '>=18'} - svelte@5.17.3: resolution: {integrity: sha512-eLgtpR2JiTgeuNQRCDcLx35Z7Lu9Qe09GPOz+gvtR9nmIZu5xgFd6oFiLGQlxLD0/u7xVyF5AUkjDVyFHe6Bvw==} engines: {node: '>=18'} @@ -2902,9 +2898,9 @@ snapshots: '@shikijs/vscode-textmate@9.3.1': {} - '@sveltejs/adapter-vercel@5.5.2(@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(rollup@4.29.1)': + '@sveltejs/adapter-vercel@5.5.2(@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(rollup@4.29.1)': dependencies: - '@sveltejs/kit': 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + '@sveltejs/kit': 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) '@vercel/nft': 0.27.10(rollup@4.29.1) esbuild: 0.24.2 transitivePeerDependencies: @@ -2912,24 +2908,6 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': - dependencies: - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) - '@types/cookie': 0.6.0 - cookie: 0.6.0 - devalue: 5.1.1 - esm-env: 1.2.1 - import-meta-resolve: 4.1.0 - kleur: 4.1.5 - magic-string: 0.30.17 - mrmime: 2.0.0 - sade: 1.8.1 - set-cookie-parser: 2.7.1 - sirv: 3.0.0 - svelte: 5.16.0 - tiny-glob: 0.2.9 - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) - '@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': dependencies: '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) @@ -2959,15 +2937,6 @@ snapshots: transitivePeerDependencies: - typescript - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': - dependencies: - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) - debug: 4.4.0 - svelte: 5.16.0 - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) - transitivePeerDependencies: - - supports-color - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': dependencies: '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) @@ -2977,19 +2946,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': - dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.16.0)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) - debug: 4.4.0 - deepmerge: 4.3.1 - kleur: 4.1.5 - magic-string: 0.30.17 - svelte: 5.16.0 - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) - vitefu: 1.0.4(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) - transitivePeerDependencies: - - supports-color - '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': dependencies: '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) @@ -3761,9 +3717,9 @@ snapshots: lru-cache@10.4.3: {} - lucide-svelte@0.469.0(svelte@5.16.0): + lucide-svelte@0.469.0(svelte@5.17.3): dependencies: - svelte: 5.16.0 + svelte: 5.17.3 lz-string@1.5.0: {} @@ -4230,14 +4186,14 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.1.1(picomatch@4.0.2)(svelte@5.16.0)(typescript@5.7.2): + svelte-check@4.1.1(picomatch@4.0.2)(svelte@5.17.3)(typescript@5.7.2): dependencies: '@jridgewell/trace-mapping': 0.3.25 chokidar: 4.0.3 fdir: 6.4.2(picomatch@4.0.2) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.16.0 + svelte: 5.17.3 typescript: 5.7.2 transitivePeerDependencies: - picomatch @@ -4249,23 +4205,6 @@ snapshots: svelte: 5.17.3 typescript: 5.7.2 - svelte@5.16.0: - dependencies: - '@ampproject/remapping': 2.3.0 - '@jridgewell/sourcemap-codec': 1.5.0 - '@types/estree': 1.0.6 - acorn: 8.14.0 - acorn-typescript: 1.4.13(acorn@8.14.0) - aria-query: 5.3.2 - axobject-query: 4.1.0 - clsx: 2.1.1 - esm-env: 1.2.1 - esrap: 1.3.2 - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.17 - zimmerframe: 1.1.2 - svelte@5.17.3: dependencies: '@ampproject/remapping': 2.3.0 diff --git a/sites/floating-ui-svelte.vercel.app/package.json b/sites/floating-ui-svelte.vercel.app/package.json index 759c1d95..24132544 100644 --- a/sites/floating-ui-svelte.vercel.app/package.json +++ b/sites/floating-ui-svelte.vercel.app/package.json @@ -20,7 +20,7 @@ "lucide-svelte": "^0.469.0", "pagefind": "^1.3.0", "shiki": "^1.24.4", - "svelte": "^5.16.0", + "svelte": "^5.17.3", "svelte-check": "^4.1.1", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/popovers/Example.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/popovers/Example.svelte index b1d4ed20..77fce28a 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/popovers/Example.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/popovers/Example.svelte @@ -20,20 +20,16 @@ // Use Floating const floating = useFloating({ whileElementsMounted: autoUpdate, - get open() { - return open; - }, + open: () => open, onOpenChange: (v) => { open = v; }, placement: "top", - get middleware() { - return [ - offset(10), - flip(), - elemArrow && arrow({ element: elemArrow }), - ]; - }, + middleware: () => [ + offset(10), + flip(), + elemArrow && arrow({ element: elemArrow }), + ], }); // Interactions diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/Example.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/Example.svelte index d0d50c59..c569417d 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/Example.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/examples/tooltips/Example.svelte @@ -42,8 +42,8 @@
From ddb75cfca31740ab2fc287c4717c3171c147cae8 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 11 Jan 2025 21:23:33 -0500 Subject: [PATCH 15/87] tests passing --- .../src/hooks/use-floating.svelte.ts | 16 +- .../src/hooks/use-position.svelte.ts | 3 +- .../test/hooks/use-floating.svelte.ts | 216 ++++++++---------- .../test/hooks/use-hover.ts | 6 +- .../hooks/wrapper-components/use-hover.svelte | 6 + .../(inner)/examples/popovers/Example.svelte | 1 + 6 files changed, 116 insertions(+), 132 deletions(-) diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index ff519c2f..f4072335 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -122,12 +122,12 @@ class FloatingOptions { reason?: OpenChangeReason, ) => void; nodeId: ReadableBox; - #stableReference = $state(null); - #stableFloating = $state(null); - reference: WritableBox = box(null); - floating: WritableBox = box(null); floatingProp = $derived.by(() => extract(this.options.floating, null)); referenceProp = $derived.by(() => extract(this.options.reference, null)); + #stableReference = $state(null); + #stableFloating = $state(null); + reference: WritableBox; + floating: WritableBox; constructor(private readonly options: UseFloatingOptions) { this.open = box.with(() => extract(options.open, true)); this.placement = box.with(() => extract(options.placement, "bottom")); @@ -156,8 +156,8 @@ class FloatingOptions { this.onFloatingChange(node); }, ); - this.reference.current = this.referenceProp; - this.floating.current = this.floatingProp; + this.reference.current = extract(this.options.reference, null); + this.floating.current = extract(this.options.floating, null); $effect.pre(() => { if (this.floatingProp) { @@ -296,6 +296,8 @@ class FloatingState { context: FloatingContext; constructor(private readonly options: FloatingOptions) { + console.log(options.reference.current); + const internalRootContext = useFloatingRootContext({ open: () => options.open.current ?? true, reference: () => options.reference.current, @@ -337,7 +339,7 @@ class FloatingState { } get reference() { - return this.#positionReference as RT | null; + return this.options.reference.current as RT | null; } set reference(node: RT | null) { diff --git a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts index c9b7c394..635b9bf8 100644 --- a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts @@ -160,6 +160,7 @@ class PositionState { ) { this.data.strategy = this.options.strategy.current; this.data.placement = this.options.placement.current; + this.update = this.update.bind(this); $effect.pre(() => { if (this.rootContext.open || !this.data.isPositioned) return; @@ -172,7 +173,7 @@ class PositionState { return this.options.whileElementsMounted( this.referenceEl, this.options.floating.current, - () => this.update(), + this.update, ); } diff --git a/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts index 725daf4b..1640e703 100644 --- a/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts @@ -10,7 +10,10 @@ import { type FloatingContext, useFloating } from "../../src/index.js"; import { useId } from "../../src/index.js"; import { withRunes } from "../internal/with-runes.svelte.js"; -function createElements(): { reference: HTMLElement; floating: HTMLElement } { +function createElements(): { + reference: Element | null; + floating: HTMLElement | null; +} { const reference = document.createElement("div"); const floating = document.createElement("div"); reference.id = useId(); @@ -19,66 +22,69 @@ function createElements(): { reference: HTMLElement; floating: HTMLElement } { } describe("useFloating", () => { + vi.mock(import("svelte"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getContext: vi.fn().mockReturnValue(null), + }; + }); + describe("elements", () => { it( "can be set", withRunes(() => { const elements = createElements(); - const floating = useFloating({ elements }); - expect(floating.elements).toEqual(elements); + const floating = useFloating(elements); + expect(floating.floating).toEqual(elements.floating); + expect(floating.reference).toEqual(elements.reference); }), ); it( "can be set through the return value", withRunes(() => { const floating = useFloating(); - const elements = createElements(); - floating.elements.reference = elements.reference; - floating.elements.floating = elements.floating; + floating.reference = elements.reference; + floating.floating = elements.floating; - expect(floating.elements).toEqual(elements); + expect(floating.reference).toEqual(elements.reference); + expect(floating.floating).toEqual(elements.floating); }), ); it( "is returned", withRunes(() => { const floating = useFloating(); - expect(floating).toHaveProperty("elements"); - }), - ); - it( - "is an object", - withRunes(() => { - const floating = useFloating(); - expect(floating.elements).toBeTypeOf("object"); - }), - ); - it( - "defaults to {}", - withRunes(() => { - const floating = useFloating(); - expect(floating.elements).toEqual({}); + expect(floating).toHaveProperty("reference"); + expect(floating).toHaveProperty("floating"); }), ); + it( "is reactive", withRunes(async () => { - let elements = $state(createElements()); + const elements = $state(createElements()); const floating = useFloating({ - get elements() { - return elements; - }, + reference: () => elements.reference, + floating: () => elements.floating, }); - expect(floating.elements).toEqual(elements); + console.log(floating.reference, floating.floating); - elements = createElements(); + expect(floating.reference).toEqual(elements.reference); + expect(floating.floating).toEqual(elements.floating); + + const newElements = createElements(); + + elements.reference = newElements.reference; + elements.floating = newElements.floating; await vi.waitFor(() => { - expect(floating.elements).toEqual(elements); + expect(floating.reference).toEqual(newElements.reference); + expect(floating.floating).toEqual(newElements.floating); }); }), ); @@ -88,15 +94,20 @@ describe("useFloating", () => { const elements = $state(createElements()); const floating = useFloating({ - get elements() { - return elements; + reference: () => elements.reference, + onReferenceChange: (v) => { + elements.reference = v; + }, + floating: () => elements.floating, + onFloatingChange: (v) => { + elements.floating = v; }, }); const newElements = createElements(); - floating.elements.reference = newElements.reference; - floating.elements.floating = newElements.floating; + floating.reference = newElements.reference; + floating.floating = newElements.floating; expect(elements).toEqual(newElements); }), @@ -109,7 +120,7 @@ describe("useFloating", () => { withRunes(async () => { const transform = true; const floating = useFloating({ - elements: createElements(), + ...createElements(), transform, }); @@ -124,7 +135,7 @@ describe("useFloating", () => { 'defaults to "true"', withRunes(async () => { const floating = useFloating({ - elements: createElements(), + ...createElements(), }); await vi.waitFor(() => { expect(floating.floatingStyles).contain( @@ -139,10 +150,8 @@ describe("useFloating", () => { let transform = $state(true); const floating = useFloating({ - elements: createElements(), - get transform() { - return transform; - }, + ...createElements(), + transform: () => transform, }); await vi.waitFor(() => { @@ -198,10 +207,8 @@ describe("useFloating", () => { let strategy: Strategy = $state("absolute"); const floating = useFloating({ - elements: createElements(), - get strategy() { - return strategy; - }, + ...createElements(), + strategy: () => strategy, }); expect(floating.strategy).toBe(strategy); @@ -251,10 +258,8 @@ describe("useFloating", () => { let placement: Placement = $state("bottom"); const floating = useFloating({ - elements: createElements(), - get placement() { - return placement; - }, + ...createElements(), + placement: () => placement, }); expect(floating.placement).toBe(placement); @@ -275,7 +280,7 @@ describe("useFloating", () => { const middleware: Array = [offset(5)]; const floating = useFloating({ - elements: createElements(), + ...createElements(), middleware, }); await vi.waitFor(() => { @@ -290,10 +295,8 @@ describe("useFloating", () => { const middleware: Array = $state([]); const floating = useFloating({ - elements: createElements(), - get middleware() { - return middleware; - }, + ...createElements(), + middleware: () => middleware, }); await vi.waitFor(() => { @@ -323,7 +326,7 @@ describe("useFloating", () => { "is returned", withRunes(() => { const floating = useFloating(); - expect(floating).toHaveProperty("open"); + expect(floating.context).toHaveProperty("open"); }), ); it( @@ -346,10 +349,8 @@ describe("useFloating", () => { let open = $state(false); const floating = useFloating({ - elements: createElements(), - get open() { - return open; - }, + ...createElements(), + open: () => open, }); expect(floating.context.open).toBe(open); @@ -370,7 +371,7 @@ describe("useFloating", () => { const whileElementsMounted = vi.fn(); useFloating({ - elements: createElements(), + ...createElements(), whileElementsMounted, }); @@ -385,7 +386,6 @@ describe("useFloating", () => { const whileElementsMounted = vi.fn(); useFloating({ - elements: undefined, whileElementsMounted, }); @@ -401,7 +401,7 @@ describe("useFloating", () => { const whileElementsMounted = vi.fn(() => cleanup); const floating = useFloating({ - elements: createElements(), + ...createElements(), whileElementsMounted, }); @@ -409,8 +409,8 @@ describe("useFloating", () => { expect(whileElementsMounted).toHaveBeenCalled(); }); - floating.elements.reference = null; - floating.elements.floating = null; + floating.reference = null; + floating.floating = null; await vi.waitFor(() => { expect(cleanup).toHaveBeenCalled(); @@ -424,7 +424,7 @@ describe("useFloating", () => { const elements = createElements(); const floating = useFloating({ - elements, + ...elements, whileElementsMounted, }); @@ -446,7 +446,7 @@ describe("useFloating", () => { const onOpenChange = vi.fn(); useFloating({ - elements: createElements(), + ...createElements(), onOpenChange, }); @@ -486,11 +486,9 @@ describe("useFloating", () => { let placement: Placement = $state("left"); const floating = useFloating({ - elements: createElements(), + ...createElements(), middleware: [offset(10)], - get placement() { - return placement; - }, + placement: () => placement, }); await vi.waitFor(() => { @@ -534,11 +532,9 @@ describe("useFloating", () => { let placement: Placement = $state("top"); const floating = useFloating({ - elements: createElements(), + ...createElements(), middleware: [offset(10)], - get placement() { - return placement; - }, + placement: () => placement, }); await vi.waitFor(() => { @@ -582,10 +578,8 @@ describe("useFloating", () => { const middleware: Array = $state([]); const floating = useFloating({ - elements: createElements(), - get middleware() { - return middleware; - }, + ...createElements(), + middleware: () => middleware, }); await vi.waitFor(() => { @@ -631,10 +625,8 @@ describe("useFloating", () => { withRunes(async () => { const floating = useFloating({ open: false, - elements: { - reference: document.createElement("div"), - floating: document.createElement("div"), - }, + reference: document.createElement("div"), + floating: document.createElement("div"), }); expect(floating.isPositioned).toBe(false); @@ -652,10 +644,8 @@ describe("useFloating", () => { let open = $state(true); const floating = useFloating({ - elements: createElements(), - get open() { - return open; - }, + ...createElements(), + open: () => open, }); await vi.waitFor(() => { @@ -735,10 +725,8 @@ describe("useFloating", () => { let open = $state(true); const floating = useFloating({ - elements: createElements(), - get open() { - return open; - }, + ...createElements(), + open: () => open, }); expect(floating.context.open).toBe(true); @@ -754,10 +742,8 @@ describe("useFloating", () => { let placement: Placement = $state("left"); const floating = useFloating({ - elements: createElements(), - get placement() { - return placement; - }, + ...createElements(), + placement: () => placement, }); expect(floating.context.placement).toBe("left"); @@ -775,10 +761,8 @@ describe("useFloating", () => { let strategy: Strategy = $state("absolute"); const floating = useFloating({ - elements: createElements(), - get strategy() { - return strategy; - }, + ...createElements(), + strategy: () => strategy, }); expect(floating.context.strategy).toBe("absolute"); @@ -796,11 +780,9 @@ describe("useFloating", () => { const middleware: Array = $state([]); const floating = useFloating({ - elements: createElements(), + ...createElements(), placement: "right", - get middleware() { - return middleware; - }, + middleware: () => middleware, }); expect(floating.context.x).toBe(0); @@ -818,11 +800,9 @@ describe("useFloating", () => { const middleware: Array = $state([]); const floating = useFloating({ - elements: createElements(), + ...createElements(), placement: "bottom", - get middleware() { - return middleware; - }, + middleware: () => middleware, }); expect(floating.context.y).toBe(0); @@ -840,10 +820,8 @@ describe("useFloating", () => { let open = $state(true); const floating = useFloating({ - elements: createElements(), - get open() { - return open; - }, + ...createElements(), + open: () => open, }); await vi.waitFor(() => { @@ -864,10 +842,8 @@ describe("useFloating", () => { const middleware: Array = $state([]); const floating = useFloating({ - elements: createElements(), - get middleware() { - return middleware; - }, + ...createElements(), + middleware: () => middleware, }); expect(floating.context.middlewareData).toEqual({}); @@ -890,17 +866,17 @@ describe("useFloating", () => { let elements = $state(createElements()); const floating = useFloating({ - get elements() { - return elements; - }, + reference: () => elements.reference, + floating: () => elements.floating, }); - - expect(floating.context.elements).toEqual(elements); + expect(floating.context.reference).toEqual(elements.reference); + expect(floating.context.floating).toEqual(elements.floating); elements = createElements(); await vi.waitFor(() => { - expect(floating.context.elements).toEqual(elements); + expect(floating.context.reference).toEqual(elements.reference); + expect(floating.context.floating).toEqual(elements.floating); }); }), ); @@ -909,9 +885,7 @@ describe("useFloating", () => { withRunes(async () => { let nodeId = $state(useId()); const floating = useFloating({ - get nodeId() { - return nodeId; - }, + nodeId: () => nodeId, }); expect(floating.context.nodeId).toBe(nodeId); diff --git a/packages/floating-ui-svelte/test/hooks/use-hover.ts b/packages/floating-ui-svelte/test/hooks/use-hover.ts index 1869f289..48f4bf25 100644 --- a/packages/floating-ui-svelte/test/hooks/use-hover.ts +++ b/packages/floating-ui-svelte/test/hooks/use-hover.ts @@ -145,15 +145,15 @@ describe("useHover", () => { }); }); - it.skip("does not show after delay when reference element changes mid delay", async () => { - const { rerender } = render(App, { delay: 100 }); + it("does not show after delay when reference element changes mid delay", async () => { + render(App, { delay: 100 }); await fireEvent.mouseEnter(screen.getByTestId("reference")); await act(async () => { vi.advanceTimersByTime(50); }); - await rerender({ showReference: false }); + await fireEvent.click(screen.getByTestId("toggle-reference")); await act(async () => { vi.advanceTimersByTime(50); diff --git a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte index b1a54d20..73bc57b2 100644 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte +++ b/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte @@ -30,6 +30,12 @@ const interactions = useInteractions([hover]); + + {#if showReference} + *``` + * + * + * @param floatingInstances + * @returns + */ +function useMergeRefs(refLikes: Array) { + return new MergeRefs(refLikes); +} + +export { MergeRefs, useMergeRefs }; +export type { BoxedRef }; diff --git a/packages/floating-ui-svelte/test/hooks/use-click.ts b/packages/floating-ui-svelte/test/hooks/use-click.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-click.ts rename to packages/floating-ui-svelte/test/hooks/use-click.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-dismiss.ts b/packages/floating-ui-svelte/test/hooks/use-dismiss.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-dismiss.ts rename to packages/floating-ui-svelte/test/hooks/use-dismiss.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts rename to packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-focus.ts b/packages/floating-ui-svelte/test/hooks/use-focus.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-focus.ts rename to packages/floating-ui-svelte/test/hooks/use-focus.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-hover.ts b/packages/floating-ui-svelte/test/hooks/use-hover.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-hover.ts rename to packages/floating-ui-svelte/test/hooks/use-hover.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-id.ts b/packages/floating-ui-svelte/test/hooks/use-id.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-id.ts rename to packages/floating-ui-svelte/test/hooks/use-id.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-interactions.svelte.ts b/packages/floating-ui-svelte/test/hooks/use-interactions.test.svelte.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-interactions.svelte.ts rename to packages/floating-ui-svelte/test/hooks/use-interactions.test.svelte.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts b/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts new file mode 100644 index 00000000..6a145db3 --- /dev/null +++ b/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { useFloating } from "../../src/index.js"; +import { withRunes } from "../internal/with-runes.svelte.js"; +import { + useMergeRefs, + type BoxedRef, +} from "../../src/hooks/use-merge-refs.svelte.js"; + +describe("useMergeRefs", () => { + vi.mock(import("svelte"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getContext: vi.fn().mockReturnValue(null), + }; + }); + + it( + "merges the references of multiple floating instances or other boxed elements", + withRunes(() => { + const ref1 = useFloating(); + const ref2 = useFloating(); + const ref3: BoxedRef = $state({ current: null }); + + const mergedRef = useMergeRefs([ref1, ref2, ref3]); + expect(mergedRef.current).toBe(null); + expect(ref1.reference).toBe(null); + expect(ref2.reference).toBe(null); + expect(ref3.current).toBe(null); + + const node = document.createElement("div"); + mergedRef.current = node; + expect(mergedRef.current).toBe(node); + expect(ref1.reference).toBe(node); + expect(ref2.reference).toBe(node); + expect(ref3.current).toBe(node); + mergedRef.current = null; + expect(mergedRef.current).toBe(null); + expect(ref1.reference).toBe(null); + expect(ref2.reference).toBe(null); + expect(ref3.current).toBe(null); + }), + ); +}); diff --git a/packages/floating-ui-svelte/test/hooks/use-role.ts b/packages/floating-ui-svelte/test/hooks/use-role.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-role.ts rename to packages/floating-ui-svelte/test/hooks/use-role.test.ts diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/floating-arrow/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/floating-arrow/+page.svelte index e902cd53..294c06c9 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/floating-arrow/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/floating-arrow/+page.svelte @@ -1,7 +1,7 @@
@@ -9,13 +9,12 @@ import { tableProps } from "./data.js";

FloatingArrow

- Renders a customizable {''} pointing arrow triangle inside the floating - element that gets automatically positioned. + Renders a customizable {""} pointing arrow + triangle inside the floating element that gets automatically positioned.

+ code={`import { FloatingArrow } from '@skeletonlabs/floating-ui-svelte';`} />
@@ -35,8 +34,7 @@ const floating = useFloating({ ]; } }); -`} - /> +`} />
- `} - /> + `} />
@@ -69,7 +66,9 @@ const floating = useFloating({

Utility Classes and Styles

Provide arbitrary utility classes using the standard attribute.

- `} /> + `} />
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-click/Example.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-click/Example.svelte index 52307dd8..b19b33c5 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-click/Example.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-click/Example.svelte @@ -1,25 +1,24 @@ -
+ {...interactions.getFloatingProps()}> Floating
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-dismiss/Example.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-dismiss/Example.svelte index 2ae97bea..707dc851 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-dismiss/Example.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-dismiss/Example.svelte @@ -1,25 +1,24 @@ -
+ {...interactions.getFloatingProps()}> Floating
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-floating/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-floating/+page.svelte index 30f29ba1..47739eae 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-floating/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-floating/+page.svelte @@ -1,7 +1,7 @@
@@ -9,20 +9,26 @@ import { tableOptions, tableReturns } from "./data.js";

useFloating

- The main Hook of the library that acts as a controller for all other Hooks and components. + The main Hook of the library that acts as a controller for all other + Hooks and components.

- +

Usage

- The useFloating Svelte hook acts as a controller for all other Floating - UI Svelte features. It handles positioning your floating elements (tooltips, popovers, etc.) relative - to an anchored element. Automatically calculates the best placement and updates it as needed, providing - access to properties for position and style. + The useFloating Svelte hook acts as a controller + for all other Floating UI Svelte features. It handles positioning your + floating elements (tooltips, popovers, etc.) relative to an anchored + element. Automatically calculates the best placement and updates it as + needed, providing access to properties for position and style.

- + Floating
- `} - /> + `} />

Note

-

Destructured variables are not supported as this would break reactivity.

+

+ Destructured variables are not supported as this would break + reactivity. +

diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-focus/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-focus/+page.svelte index ab366c73..9c9c997c 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-focus/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-focus/+page.svelte @@ -1,8 +1,8 @@
@@ -10,10 +10,13 @@ import { tableOptions } from "./data.js";

useFocus

- Opens the floating element while the reference element has focus, like CSS + Opens the floating element while the reference element has focus, + like CSS :focus.

- +
@@ -21,10 +24,11 @@ import { tableOptions } from "./data.js";

Usage

- This Hook returns event handler props. To use it, pass it the context object returned from useFloating(), and then feed its result into the useInteractions() array. The returned - prop getters are then spread onto the elements for rendering. + This Hook returns event handler props. To use it, pass it the + context object returned from useFloating(), and then feed its result into the + useInteractions() array. The returned prop + getters are then spread onto the elements for rendering.

diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-focus/Example.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-focus/Example.svelte index ab5a4278..a75d6df4 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-focus/Example.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-focus/Example.svelte @@ -1,25 +1,24 @@ -
+ {...interactions.getFloatingProps()}> Floating
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-hover/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-hover/+page.svelte index 8fad14ff..34e8c679 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-hover/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-hover/+page.svelte @@ -1,7 +1,7 @@
@@ -9,10 +9,13 @@ import { tableOptions } from "./data.js";

useHover

- Opens the floating element while hovering over the reference element, like CSS + Opens the floating element while hovering over the reference + element, like CSS :hover.

- +
@@ -25,8 +28,7 @@ import { useFloating, useInteractions, useHover } from '@skeletonlabs/floating-u const floating = useFloating(); const hover = useHover(floating.context); const interactions = useInteractions([hover]); -`} - /> +`} /> Tooltip
- `} - /> + `} />
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-interactions/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-interactions/+page.svelte index 25e79947..990c44c7 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-interactions/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-interactions/+page.svelte @@ -1,7 +1,7 @@
@@ -11,16 +11,16 @@ import { tableReturns } from "./data.js";

A hook to merge or compose interaction event handlers together.

+ code={`import { useInteractions } from '@skeletonlabs/floating-ui-svelte';`} />

Usage

- The useInteractions Svelte hook allows you to consume multiple interactions. - It ensures that event listeners from different hooks are properly registered instead of being overruled - by one another. + The useInteractions Svelte hook allows you + to consume multiple interactions. It ensures that event listeners from + different hooks are properly registered instead of being overruled by + one another.

+`} /> Floating
- `} - /> + `} />

- When you want to apply an event handler to an element that uses a props getter, make sure to - pass it through the getter instead of applying it directly: + When you want to apply an event handler to an element that uses a + props getter, make sure to pass it through the getter instead of + applying it directly:

Reference
- `} - /> + `} />

- This will ensure all event handlers will be registered rather being overruled by each-other. + This will ensure all event handlers will be registered rather being + overruled by each-other.

diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-role/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-role/+page.svelte index d16b8804..fa91aa96 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-role/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/use-role/+page.svelte @@ -1,7 +1,7 @@
@@ -9,14 +9,17 @@ import { tableOptions } from "./data.js";

useRole

- Adds base screen reader props to the reference and floating elements for a given + Adds base screen reader props to the reference and floating elements + for a given role.

- +

- This is useful to automatically apply ARIA props to the reference and floating elements to - ensure they’re accessible to assistive technology, including item elements if narrowly - specified. + This is useful to automatically apply ARIA props to the reference + and floating elements to ensure they’re accessible to assistive + technology, including item elements if narrowly specified.

@@ -30,8 +33,7 @@ import { useFloating, useInteractions, useRole } from '@skeletonlabs/floating-ui const floating = useFloating(); const role = useRole(floating.context, { role: 'tooltip' }); const interactions = useInteractions([role]); -`} - /> +`} /> Tooltip
- `} - /> + `} />
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/utilities/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/utilities/+page.svelte index 410ce1a6..a49bf738 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/utilities/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/api/utilities/+page.svelte @@ -1,5 +1,5 @@
@@ -12,8 +12,8 @@ import CodeBlock from "$lib/components/CodeBlock/CodeBlock.svelte";

useId

- Generates a unique identifier string. This function combines a random string and an - incrementing counter to ensure uniqueness. + Generates a unique identifier string. This function combines a + random string and an incrementing counter to ensure uniqueness.

+`} />
From bae10ab7f4b62127d598d20e9c5c5c73e9938e33 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 12 Jan 2025 20:20:33 -0500 Subject: [PATCH 18/87] progress --- packages/floating-ui-svelte/package.json | 3 +- .../floating-focus-manager.svelte | 672 ++++++++++++++++++ .../visually-hidden-dismiss.svelte | 28 + .../floating-portal/floating-portal.svelte | 171 +++++ .../floating-portal/hooks.svelte.ts | 109 +++ .../floating-portal/portal-consumer.svelte | 9 + .../components/floating-portal/portal.svelte | 94 +++ .../src/components/focus-guard.svelte | 77 ++ .../src/hooks/use-merge-refs.svelte.ts | 9 +- .../floating-ui-svelte/src/internal/dom.ts | 62 +- .../src/internal/enqueue-focus.ts | 25 + .../src/internal/get-ancestors.ts | 20 + .../internal/get-floating-focus-element.ts | 17 + .../src/internal/is-typeable-element.ts | 9 +- .../src/internal/mark-others.ts | 152 ++++ .../src/internal/style-object-to-string.ts | 2 +- .../src/internal/tabbable.ts | 75 ++ packages/floating-ui-svelte/src/types.ts | 12 + pnpm-lock.yaml | 3 + 19 files changed, 1523 insertions(+), 26 deletions(-) create mode 100644 packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte create mode 100644 packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte create mode 100644 packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte create mode 100644 packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts create mode 100644 packages/floating-ui-svelte/src/components/floating-portal/portal-consumer.svelte create mode 100644 packages/floating-ui-svelte/src/components/floating-portal/portal.svelte create mode 100644 packages/floating-ui-svelte/src/components/focus-guard.svelte create mode 100644 packages/floating-ui-svelte/src/internal/enqueue-focus.ts create mode 100644 packages/floating-ui-svelte/src/internal/get-ancestors.ts create mode 100644 packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts create mode 100644 packages/floating-ui-svelte/src/internal/mark-others.ts create mode 100644 packages/floating-ui-svelte/src/internal/tabbable.ts diff --git a/packages/floating-ui-svelte/package.json b/packages/floating-ui-svelte/package.json index 765df8dd..b219b27c 100644 --- a/packages/floating-ui-svelte/package.json +++ b/packages/floating-ui-svelte/package.json @@ -47,6 +47,7 @@ "@floating-ui/dom": "^1.6.12", "@floating-ui/utils": "^0.2.8", "esm-env": "^1.2.1", - "style-to-object": "^1.0.8" + "style-to-object": "^1.0.8", + "tabbable": "^6.2.0" } } diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte new file mode 100644 index 00000000..0e263fba --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -0,0 +1,672 @@ + + + diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte new file mode 100644 index 00000000..346d9911 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte new file mode 100644 index 00000000..8503fc4b --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -0,0 +1,171 @@ + + + + +{#if shouldRenderGuards && portalNode.current} + { + if (isOutsideEvent(event, portalNode.current)) { + beforeInsideRef.current?.focus(); + } else { + const prevTabbable = + getPreviousTabbable() || focusManagerState?.domReference; + prevTabbable?.focus(); + } + }} /> + + +{/if} + +{#if portalNode.current} + +{/if} + +{#if shouldRenderGuards && portalNode.current} + { + if (isOutsideEvent(event, portalNode.current)) { + afterInsideRef.current?.focus(); + } else { + const nextTabbable = + getNextTabbable() || focusManagerState?.domReference; + nextTabbable?.focus(); + + focusManagerState?.closeOnFocusOut && + focusManagerState?.onOpenChange(false, event, "focus-out"); + } + }} /> +{/if} diff --git a/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts b/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts new file mode 100644 index 00000000..fc1e6438 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts @@ -0,0 +1,109 @@ +import { useId } from "../../hooks/use-id.js"; +import { Context } from "../../internal/context.js"; +import { createAttribute } from "../../internal/dom.js"; +import { extract } from "../../internal/extract.js"; +import { watch } from "../../internal/watch.svelte.js"; +import type { MaybeGetter, OnOpenChange } from "../../types.js"; + +type FocusManagerState = { + modal: boolean; + open: boolean; + onOpenChange: OnOpenChange; + domReference: Element | null; + closeOnFocusOut: boolean; +} | null; + +const PortalContext = new Context<{ + preserveTabOrder: boolean; + portalNode: HTMLElement | null; + setFocusManagerState: (state: FocusManagerState) => void; + beforeInsideRef: { current: HTMLSpanElement | null }; + afterInsideRef: { current: HTMLSpanElement | null }; + beforeOutsideRef: { current: HTMLSpanElement | null }; + afterOutsideRef: { current: HTMLSpanElement | null }; +} | null>("PortalContext"); + +const attr = createAttribute("portal"); + +function usePortalContext() { + return PortalContext.getOr(null); +} + +interface UseFloatingPortalNodeProps { + id?: MaybeGetter; + root?: MaybeGetter; +} + +function useFloatingPortalNode(props: UseFloatingPortalNodeProps = {}) { + const id = $derived(extract(props.id)); + const root = $derived(extract(props.root)); + + const uniqueId = useId(); + const portalContext = usePortalContext(); + + let portalNode = $state(null); + + watch( + () => portalNode, + () => { + return () => { + portalNode?.remove(); + queueMicrotask(() => { + portalNode = null; + }); + }; + }, + ); + + watch( + () => id, + (id) => { + if (portalNode) return; + const existingIdRoot = id ? document.getElementById(id) : null; + if (!existingIdRoot) return; + + const subRoot = document.createElement("div"); + subRoot.id = uniqueId; + subRoot.setAttribute(attr, ""); + existingIdRoot.appendChild(subRoot); + portalNode = subRoot; + }, + ); + + watch( + [() => id, () => root, () => portalContext?.portalNode], + ([id, root, portalContextNode]) => { + // Wait for the root to exist before creating the portal node. + if (root === null) return; + if (portalNode) return; + + let container = root || portalContextNode; + container = container || document.body; + + let idWrapper: HTMLDivElement | null = null; + if (id) { + idWrapper = document.createElement("div"); + idWrapper.id = id; + container.appendChild(idWrapper); + } + + const subRoot = document.createElement("div"); + + subRoot.id = uniqueId; + subRoot.setAttribute(attr, ""); + + container = idWrapper || container; + container.appendChild(subRoot); + portalNode = subRoot; + }, + ); + + return { + get current() { + return portalNode; + }, + }; +} + +export { usePortalContext, useFloatingPortalNode, PortalContext }; +export type { UseFloatingPortalNodeProps, FocusManagerState }; diff --git a/packages/floating-ui-svelte/src/components/floating-portal/portal-consumer.svelte b/packages/floating-ui-svelte/src/components/floating-portal/portal-consumer.svelte new file mode 100644 index 00000000..da153b75 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-portal/portal-consumer.svelte @@ -0,0 +1,9 @@ + + +{#key children} + {@render children?.()} +{/key} diff --git a/packages/floating-ui-svelte/src/components/floating-portal/portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/portal.svelte new file mode 100644 index 00000000..0b0b3fea --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-portal/portal.svelte @@ -0,0 +1,94 @@ + + + + +{#if disabled} + {@render children?.()} +{/if} diff --git a/packages/floating-ui-svelte/src/components/focus-guard.svelte b/packages/floating-ui-svelte/src/components/focus-guard.svelte new file mode 100644 index 00000000..507b5b7a --- /dev/null +++ b/packages/floating-ui-svelte/src/components/focus-guard.svelte @@ -0,0 +1,77 @@ + + + + + + {@render children?.()} + diff --git a/packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts index 29c23800..923e8119 100644 --- a/packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts @@ -12,7 +12,9 @@ interface BoxedRef { class MergeRefs { #current = $state(null); constructor( - private readonly floatingOrRef: Array, + private readonly floatingOrRef: Array< + FloatingState | BoxedRef | null | undefined + >, ) {} get current() { @@ -21,6 +23,7 @@ class MergeRefs { set current(node: ReferenceType | null) { for (const arg of this.floatingOrRef) { + if (!arg) continue; if (arg instanceof FloatingState) { arg.reference = node; continue; @@ -59,7 +62,9 @@ class MergeRefs { * @param floatingInstances * @returns */ -function useMergeRefs(refLikes: Array) { +function useMergeRefs( + refLikes: Array, +) { return new MergeRefs(refLikes); } diff --git a/packages/floating-ui-svelte/src/internal/dom.ts b/packages/floating-ui-svelte/src/internal/dom.ts index 4b144a56..27f8eb1f 100644 --- a/packages/floating-ui-svelte/src/internal/dom.ts +++ b/packages/floating-ui-svelte/src/internal/dom.ts @@ -47,27 +47,6 @@ function contains(parent?: Element | null, child?: Element | null) { return false; } -function isVirtualPointerEvent(event: PointerEvent) { - if (isJSDOM()) { - return false; - } - return ( - (!isAndroid() && event.width === 0 && event.height === 0) || - (isAndroid() && - event.width === 1 && - event.height === 1 && - event.pressure === 0 && - event.detail === 0 && - event.pointerType === "mouse") || - // iOS VoiceOver returns 0.333• for width/height. - (event.width < 1 && - event.height < 1 && - event.pressure === 0 && - event.detail === 0 && - event.pointerType === "touch") - ); -} - function getTarget(event: Event) { if ("composedPath" in event) { return event.composedPath()[0]; @@ -115,6 +94,46 @@ function isPointerType(str: string): str is PointerType { return pointerTypes.includes(str as PointerType); } +function stopEvent(event: Event) { + event.preventDefault(); + event.stopPropagation(); +} + +// License: https://github.com/adobe/react-spectrum/blob/b35d5c02fe900badccd0cf1a8f23bb593419f238/packages/@react-aria/utils/src/isVirtualEvent.ts +export function isVirtualClick(event: MouseEvent | PointerEvent): boolean { + // FIXME: Firefox is now emitting a deprecation warning for `mozInputSource`. + // Try to find a workaround for this. `react-aria` source still has the check. + // biome-ignore lint/suspicious/noExplicitAny: + if ((event as any).mozInputSource === 0 && event.isTrusted) { + return true; + } + + if (isAndroid() && (event as PointerEvent).pointerType) { + return event.type === "click" && event.buttons === 1; + } + + return event.detail === 0 && !(event as PointerEvent).pointerType; +} + +function isVirtualPointerEvent(event: PointerEvent) { + if (isJSDOM()) return false; + return ( + (!isAndroid() && event.width === 0 && event.height === 0) || + (isAndroid() && + event.width === 1 && + event.height === 1 && + event.pressure === 0 && + event.detail === 0 && + event.pointerType === "mouse") || + // iOS VoiceOver returns 0.333• for width/height. + (event.width < 1 && + event.height < 1 && + event.pressure === 0 && + event.detail === 0 && + event.pointerType === "touch") + ); +} + export { getDocument, activeElement, @@ -126,6 +145,7 @@ export { isRootElement, isMouseLikePointerType, isPointerType, + stopEvent, }; export type { PointerType }; diff --git a/packages/floating-ui-svelte/src/internal/enqueue-focus.ts b/packages/floating-ui-svelte/src/internal/enqueue-focus.ts new file mode 100644 index 00000000..a0a49933 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/enqueue-focus.ts @@ -0,0 +1,25 @@ +import type { FocusableElement } from "tabbable"; + +interface Options { + preventScroll?: boolean; + cancelPrevious?: boolean; + sync?: boolean; +} + +let rafId = 0; +function enqueueFocus(el: FocusableElement | null, options: Options = {}) { + const { + preventScroll = false, + cancelPrevious = true, + sync = false, + } = options; + cancelPrevious && cancelAnimationFrame(rafId); + const exec = () => el?.focus({ preventScroll }); + if (sync) { + exec(); + } else { + rafId = requestAnimationFrame(exec); + } +} + +export { enqueueFocus }; diff --git a/packages/floating-ui-svelte/src/internal/get-ancestors.ts b/packages/floating-ui-svelte/src/internal/get-ancestors.ts new file mode 100644 index 00000000..783674d5 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/get-ancestors.ts @@ -0,0 +1,20 @@ +import type { FloatingNodeType, ReferenceType } from "../types.js"; + +export function getAncestors( + nodes: Array>, + id: string | undefined, +) { + let allAncestors: Array> = []; + let currentParentId = nodes.find((node) => node.id === id)?.parentId; + + while (currentParentId) { + const currentNode = nodes.find((node) => node.id === currentParentId); + currentParentId = currentNode?.parentId; + + if (currentNode) { + allAncestors = allAncestors.concat(currentNode); + } + } + + return allAncestors; +} diff --git a/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts new file mode 100644 index 00000000..fceaa818 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts @@ -0,0 +1,17 @@ +export const FOCUSABLE_ATTRIBUTE = "data-floating-ui-focusable"; + +export function getFloatingFocusElement( + floatingElement: HTMLElement | null | undefined, +): HTMLElement | null { + if (!floatingElement) { + return null; + } + // Try to find the element that has `{...getFloatingProps()}` spread on it. + // This indicates the floating element is acting as a positioning wrapper, and + // so focus should be managed on the child element with the event handlers and + // aria props. + return floatingElement.hasAttribute(FOCUSABLE_ATTRIBUTE) + ? floatingElement + : floatingElement.querySelector(`[${FOCUSABLE_ATTRIBUTE}]`) || + floatingElement; +} diff --git a/packages/floating-ui-svelte/src/internal/is-typeable-element.ts b/packages/floating-ui-svelte/src/internal/is-typeable-element.ts index a77bb97a..b48262c0 100644 --- a/packages/floating-ui-svelte/src/internal/is-typeable-element.ts +++ b/packages/floating-ui-svelte/src/internal/is-typeable-element.ts @@ -8,4 +8,11 @@ function isTypeableElement(element: unknown): boolean { return isHTMLElement(element) && element.matches(TYPEABLE_SELECTOR); } -export { TYPEABLE_SELECTOR, isTypeableElement }; +function isTypeableCombobox(element: Element | null) { + if (!element) return false; + return ( + element.getAttribute("role") === "combobox" && isTypeableElement(element) + ); +} + +export { TYPEABLE_SELECTOR, isTypeableElement, isTypeableCombobox }; diff --git a/packages/floating-ui-svelte/src/internal/mark-others.ts b/packages/floating-ui-svelte/src/internal/mark-others.ts new file mode 100644 index 00000000..5bd0b620 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/mark-others.ts @@ -0,0 +1,152 @@ +import { getDocument } from "./dom.js"; +import { getNodeName } from "@floating-ui/utils/dom"; + +type Undo = () => void; + +let counterMap = new WeakMap(); +let uncontrolledElementsSet = new WeakSet(); +let markerMap: Record> = {}; +let lockCount = 0; + +export function supportsInert(): boolean { + return typeof HTMLElement !== "undefined" && "inert" in HTMLElement.prototype; +} + +function unwrapHost(node: Element | ShadowRoot): Element | null { + return ( + node && + ((node as ShadowRoot).host || unwrapHost(node.parentNode as Element)) + ); +} + +function correctElements(parent: HTMLElement, targets: Element[]): Element[] { + return targets + .map((target) => { + if (parent.contains(target)) return target; + const correctedTarget = unwrapHost(target); + if (parent.contains(correctedTarget)) return correctedTarget; + return null; + }) + .filter((x): x is Element => x != null); +} + +function applyAttributeToOthers( + uncorrectedAvoidElements: Element[], + body: HTMLElement, + ariaHidden: boolean, + inert: boolean, +): Undo { + const markerName = "data-floating-ui-inert"; + const controlAttribute = inert ? "inert" : ariaHidden ? "aria-hidden" : null; + const avoidElements = correctElements(body, uncorrectedAvoidElements); + const elementsToKeep = new Set(); + const elementsToStop = new Set(avoidElements); + const hiddenElements: Element[] = []; + + if (!markerMap[markerName]) { + markerMap[markerName] = new WeakMap(); + } + + const markerCounter = markerMap[markerName]; + + avoidElements.forEach(keep); + deep(body); + elementsToKeep.clear(); + + function keep(el: Node | undefined) { + if (!el || elementsToKeep.has(el)) return; + + elementsToKeep.add(el); + el.parentNode && keep(el.parentNode); + } + + function deep(parent: Element | null) { + if (!parent || elementsToStop.has(parent)) return; + + [].forEach.call(parent.children, (node: Element) => { + if (getNodeName(node) === "script") return; + + if (elementsToKeep.has(node)) { + deep(node); + } else { + const attr = controlAttribute + ? node.getAttribute(controlAttribute) + : null; + const alreadyHidden = attr !== null && attr !== "false"; + const currentCounterValue = counterMap.get(node) || 0; + const counterValue = controlAttribute + ? currentCounterValue + 1 + : currentCounterValue; + const markerValue = (markerCounter.get(node) || 0) + 1; + + counterMap.set(node, counterValue); + markerCounter.set(node, markerValue); + hiddenElements.push(node); + + if (counterValue === 1 && alreadyHidden) { + uncontrolledElementsSet.add(node); + } + + if (markerValue === 1) { + node.setAttribute(markerName, ""); + } + + if (!alreadyHidden && controlAttribute) { + node.setAttribute(controlAttribute, "true"); + } + } + }); + } + + lockCount++; + + return () => { + for (const element of hiddenElements) { + const currentCounterValue = counterMap.get(element) || 0; + const counterValue = controlAttribute + ? currentCounterValue - 1 + : currentCounterValue; + const markerValue = (markerCounter.get(element) || 0) - 1; + + counterMap.set(element, counterValue); + markerCounter.set(element, markerValue); + + if (!counterValue) { + if (!uncontrolledElementsSet.has(element) && controlAttribute) { + element.removeAttribute(controlAttribute); + } + + uncontrolledElementsSet.delete(element); + } + + if (!markerValue) { + element.removeAttribute(markerName); + } + } + + lockCount--; + + if (!lockCount) { + counterMap = new WeakMap(); + counterMap = new WeakMap(); + uncontrolledElementsSet = new WeakSet(); + markerMap = {}; + } + }; +} + +function markOthers( + avoidElements: Element[], + ariaHidden = false, + inert = false, +): Undo { + const body = getDocument(avoidElements[0]).body; + return applyAttributeToOthers( + avoidElements.concat(Array.from(body.querySelectorAll("[aria-live]"))), + body, + ariaHidden, + inert, + ); +} + +export { markOthers }; diff --git a/packages/floating-ui-svelte/src/internal/style-object-to-string.ts b/packages/floating-ui-svelte/src/internal/style-object-to-string.ts index b00a1ec1..34b4537a 100644 --- a/packages/floating-ui-svelte/src/internal/style-object-to-string.ts +++ b/packages/floating-ui-svelte/src/internal/style-object-to-string.ts @@ -15,7 +15,7 @@ function styleStringToObject( try { return parse(style) as PropertiesHyphen; } catch (err) { - error("Invalid style string provided via `style` prop."); + error("Invalid style string provided via `style` prop. No styles applied."); return {}; } } diff --git a/packages/floating-ui-svelte/src/internal/tabbable.ts b/packages/floating-ui-svelte/src/internal/tabbable.ts new file mode 100644 index 00000000..d3265bca --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/tabbable.ts @@ -0,0 +1,75 @@ +import { activeElement, contains, getDocument } from "./dom.js"; +import { tabbable } from "tabbable"; + +function getTabbableOptions() { + return { + getShadowRoot: true, + displayCheck: + // JSDOM does not support the `tabbable` library. To solve this we can + // check if `ResizeObserver` is a real function (not polyfilled), which + // determines if the current environment is JSDOM-like. + typeof ResizeObserver === "function" && + ResizeObserver.toString().includes("[native code]") + ? "full" + : "none", + } as const; +} + +function getTabbableIn(container: HTMLElement, direction: "next" | "prev") { + const allTabbable = tabbable(container, getTabbableOptions()); + + if (direction === "prev") { + allTabbable.reverse(); + } + + const activeIndex = allTabbable.indexOf( + activeElement(getDocument(container)) as HTMLElement, + ); + const nextTabbableElements = allTabbable.slice(activeIndex + 1); + return nextTabbableElements[0]; +} + +function getNextTabbable() { + return getTabbableIn(document.body, "next"); +} + +function getPreviousTabbable() { + return getTabbableIn(document.body, "prev"); +} + +function isOutsideEvent(event: FocusEvent, container?: Element | null) { + const containerElement = container || (event.currentTarget as Element); + const relatedTarget = event.relatedTarget as HTMLElement | null; + return !relatedTarget || !contains(containerElement, relatedTarget); +} + +function disableFocusInside(container: HTMLElement) { + const tabbableElements = tabbable(container, getTabbableOptions()); + for (const element of tabbableElements) { + element.dataset.tabindex = element.getAttribute("tabindex") || ""; + element.setAttribute("tabindex", "-1"); + } +} + +function enableFocusInside(container: HTMLElement) { + const elements = container.querySelectorAll("[data-tabindex]"); + for (const element of elements) { + const tabindex = element.dataset.tabindex; + delete element.dataset.tabindex; + if (tabindex) { + element.setAttribute("tabindex", tabindex); + } else { + element.removeAttribute("tabindex"); + } + } +} + +export { + getTabbableOptions, + getTabbableIn, + getNextTabbable, + getPreviousTabbable, + isOutsideEvent, + disableFocusInside, + enableFocusInside, +}; diff --git a/packages/floating-ui-svelte/src/types.ts b/packages/floating-ui-svelte/src/types.ts index c9b66c4b..1d63bb10 100644 --- a/packages/floating-ui-svelte/src/types.ts +++ b/packages/floating-ui-svelte/src/types.ts @@ -9,6 +9,7 @@ type OpenChangeReason = | "click" | "hover" | "focus" + | "focus-out" | "list-navigation" | "safe-polygon"; @@ -16,6 +17,8 @@ type ReferenceType = Element | VirtualElement; type NarrowedElement = T extends Element ? T : Element; +type Boxed = { current: T }; + interface FloatingEvents { // biome-ignore lint/suspicious/noExplicitAny: From the port emit(event: T, data?: any): void; @@ -78,6 +81,13 @@ type WhileElementsMounted = ( type Getter = () => T; type MaybeGetter = T | Getter; +interface WithRef { + /** + * A bindable reference to the element. + */ + ref: T | null; +} + export type { OpenChangeReason, FloatingEvents, @@ -90,4 +100,6 @@ export type { Getter, MaybeGetter, WhileElementsMounted, + WithRef, + Boxed, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c087988..9432d7f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: style-to-object: specifier: ^1.0.8 version: 1.0.8 + tabbable: + specifier: ^6.2.0 + version: 6.2.0 devDependencies: '@sveltejs/kit': specifier: ^2.15.1 From 6f2f035214104a447ab47cfe8708eabdadefe67c Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 13 Jan 2025 13:41:24 -0500 Subject: [PATCH 19/87] floating focus manager --- .../floating-focus-manager.svelte | 115 +++++++++++++++--- .../visually-hidden-dismiss.svelte | 2 +- .../src/hooks/use-merge-refs.svelte.ts | 19 ++- 3 files changed, 105 insertions(+), 31 deletions(-) diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index 0e263fba..c961af37 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -3,7 +3,12 @@ import type { FloatingRootContext } from "../../hooks/use-floating-root-context.svelte.js"; import type { FloatingContext } from "../../hooks/use-floating.svelte.js"; import { getNodeName, isHTMLElement } from "@floating-ui/utils/dom"; - import { getTabbableOptions } from "../../internal/tabbable.js"; + import { + getNextTabbable, + getPreviousTabbable, + getTabbableOptions, + isOutsideEvent, + } from "../../internal/tabbable.js"; import { isTabbable, tabbable, type FocusableElement } from "tabbable"; import { isTypeableCombobox } from "../../internal/is-typeable-element.js"; import { markOthers, supportsInert } from "../../internal/mark-others.js"; @@ -28,7 +33,7 @@ import type { Boxed, OpenChangeReason } from "../../types.js"; import { useMergeRefs } from "../../hooks/use-merge-refs.svelte.js"; import { watch } from "../../internal/watch.svelte.js"; - import { HIDDEN_STYLES_STRING } from "../focus-guard.svelte"; + import FocusGuard, { HIDDEN_STYLES_STRING } from "../focus-guard.svelte"; const LIST_LIMIT = 20; let previouslyFocusedElements: Element[] = []; @@ -141,11 +146,16 @@ outsideElementsInert?: boolean; } + type DismissButtonSnippetProps = { + ref: Boxed; + }; + export type { FloatingFocusManagerProps }; + +{#snippet DismissButton({ ref }: DismissButtonSnippetProps)} + {#if !disabled && visuallyHiddenDismiss && modal} + context.onOpenChange(false, event)}> + {typeof visuallyHiddenDismiss === "string" + ? visuallyHiddenDismiss + : "Dismiss"} + + {/if} +{/snippet} + +{#if shouldRenderGuards} + { + if (modal) { + const els = getTabbableElements(); + enqueueFocus( + order[0] === "reference" ? els[0] : els[els.length - 1] + ); + } else if ( + portalContext?.preserveTabOrder && + portalContext.portalNode + ) { + preventReturnFocus = false; + if (isOutsideEvent(event, portalContext.portalNode)) { + const nextTabbable = + getNextTabbable() || context.domReference; + nextTabbable?.focus(); + } else { + portalContext.beforeOutsideRef.current?.focus(); + } + } + }} /> +{/if} + +{#if !isUntrappedTypeableCombobox} + {@render DismissButton({ ref: startDismissButtonRef })} +{/if} +{@render children?.()} +{@render DismissButton({ ref: endDismissButtonRef })} +{#if shouldRenderGuards} + { + if (modal) { + enqueueFocus(getTabbableElements()[0]); + } else if ( + portalContext?.preserveTabOrder && + portalContext.portalNode + ) { + if (closeOnFocusOut) { + preventReturnFocus = true; + } + + if (isOutsideEvent(event, portalContext.portalNode)) { + const prevTabbable = + getPreviousTabbable() || context.domReference; + prevTabbable?.focus(); + } else { + portalContext.afterOutsideRef.current?.focus(); + } + } + }} /> +{/if} diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte index 346d9911..d731276e 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte @@ -5,7 +5,7 @@ interface VisuallyHiddenDismissProps extends HTMLButtonAttributes, - WithRef {} + WithRef {} export type { VisuallyHiddenDismissProps }; diff --git a/packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts index 923e8119..279c3459 100644 --- a/packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts @@ -1,19 +1,15 @@ -import type { ReferenceType } from "../types.js"; +import type { Boxed } from "../types.js"; import { FloatingState } from "./use-floating.svelte.js"; -interface BoxedRef { - current: ReferenceType | null; -} - /** * Merges the references of either floating instances or refs into a single reference * that can be accessed and set via the `.current` property. */ -class MergeRefs { - #current = $state(null); +class MergeRefs { + #current = $state(null); constructor( private readonly floatingOrRef: Array< - FloatingState | BoxedRef | null | undefined + FloatingState | Boxed | null | undefined >, ) {} @@ -21,7 +17,7 @@ class MergeRefs { return this.#current; } - set current(node: ReferenceType | null) { + set current(node: T | null) { for (const arg of this.floatingOrRef) { if (!arg) continue; if (arg instanceof FloatingState) { @@ -62,11 +58,10 @@ class MergeRefs { * @param floatingInstances * @returns */ -function useMergeRefs( - refLikes: Array, +function useMergeRefs( + refLikes: Array | null | undefined>, ) { return new MergeRefs(refLikes); } export { MergeRefs, useMergeRefs }; -export type { BoxedRef }; From 7cce28a0bbe9020bd88362463cd37bcd2a96c1b0 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 14 Jan 2025 18:49:47 -0500 Subject: [PATCH 20/87] some stuff --- .../floating-focus-manager.svelte | 7 +- .../floating-portal/floating-portal.svelte | 38 +++---- .../floating-portal/hooks.svelte.ts | 19 ++-- .../floating-tree/floating-node.svelte | 3 +- .../floating-tree/floating-tree.svelte | 3 +- .../components/floating-tree/hooks.svelte.ts | 23 +++- .../src/hooks/use-floating.svelte.ts | 2 +- packages/floating-ui-svelte/src/index.ts | 13 +++ ...oating-arrow.ts => floating-arrow.test.ts} | 2 +- .../floating-focus-manager/dialog.svelte | 62 +++++++++++ .../floating-focus-manager.test.ts | 103 ++++++++++++++++++ .../floating-focus-manager/main.svelte | 72 ++++++++++++ .../nested-dialog.svelte | 18 +++ .../nested-nested.svelte | 32 ++++++ .../test/hooks/use-floating.test.svelte.ts | 2 - packages/floating-ui-svelte/vite.config.ts | 2 +- .../src/routes/(inner)/sink/+page.svelte | 5 + .../src/routes/(inner)/sink/dialog.svelte | 63 +++++++++++ .../routes/(inner)/sink/nested-dialog.svelte | 20 ++++ .../routes/(inner)/sink/nested-nested.svelte | 34 ++++++ 20 files changed, 474 insertions(+), 49 deletions(-) rename packages/floating-ui-svelte/test/components/{floating-arrow.ts => floating-arrow.test.ts} (97%) create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/dialog.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/main.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/nested-dialog.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/nested-nested.svelte create mode 100644 sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte create mode 100644 sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/dialog.svelte create mode 100644 sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/nested-dialog.svelte create mode 100644 sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/nested-nested.svelte diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index c961af37..bba0d9ce 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -201,6 +201,9 @@ let tabbableIndex = $state(-1); const isInsidePortal = portalContext != null; + + console.log("isInsidePortal", isInsidePortal); + const floatingFocusElement = $derived( getFloatingFocusElement(context.floating) ); @@ -422,8 +425,8 @@ context.floating, ...portalNodes, ...ancestorFloatingNodes, - startDismissButtonRef, - endDismissButtonRef, + startDismissButtonRef.current, + endDismissButtonRef.current, beforeGuardRef.current, afterGuardRef.current, portalContext?.beforeOutsideRef.current, diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index 8503fc4b..f7a2a287 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -41,12 +41,12 @@ + + + {@render reference(referenceRef, ints.getReferenceProps())} + + {#if open} + +
+ {@render content(close)} +
+
+ {/if} +
+
diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts new file mode 100644 index 00000000..2f10c9c0 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import Main from "./main.svelte"; +import { tick } from "svelte"; +import NestedNested from "./nested-nested.svelte"; + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("FloatingFocusManager", () => { + describe("initialFocus", () => { + it("handles numbers", async () => { + render(Main); + + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); + + expect(screen.getByTestId("one")).toHaveFocus(); + const incrementButton = screen.getByTestId("increment-initialFocus"); + + await fireEvent.click(incrementButton); + expect(screen.getByTestId("two")).not.toHaveFocus(); + + await fireEvent.click(incrementButton); + expect(screen.getByTestId("three")).not.toHaveFocus(); + }); + + it("handles elements", async () => { + render(Main, { initialFocus: "two" }); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); + + expect(screen.getByTestId("two")).toHaveFocus(); + }); + + it("respects autofocus", async () => { + render(Main, { renderInput: true }); + + await fireEvent.click(screen.getByTestId("reference")); + expect(screen.getByTestId("input")).toHaveFocus(); + }); + }); + + describe("returnFocus", () => { + it("respects true", async () => { + render(Main); + + screen.getByTestId("reference").focus(); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); + + expect(screen.getByTestId("one")).toHaveFocus(); + + await fireEvent.click(screen.getByTestId("three")); + expect(screen.getByTestId("reference")).toHaveFocus(); + }); + + it("respects false", async () => { + render(Main, { returnFocus: false }); + + screen.getByTestId("reference").focus(); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); + + expect(screen.getByTestId("one")).toHaveFocus(); + + await fireEvent.click(screen.getByTestId("three")); + expect(screen.getByTestId("reference")).not.toHaveFocus(); + }); + + it("respects ref", async () => { + render(Main, { returnFocus: "inputRef" }); + screen.getByTestId("reference").focus(); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); + + expect(screen.getByTestId("one")).toHaveFocus(); + + await fireEvent.click(screen.getByTestId("three")); + expect(screen.getByTestId("reference")).not.toHaveFocus(); + expect(screen.getByTestId("focus-target")).toHaveFocus(); + }); + + it("always returns to the reference for nested elements", async () => { + render(NestedNested); + await fireEvent.click(screen.getByTestId("open-dialog")); + await fireEvent.click(screen.getByTestId("open-nested-dialog")); + + expect(screen.queryByTestId("close-nested-dialog")).toBeInTheDocument(); + + await fireEvent.pointerDown(document.body); + + expect( + screen.queryByTestId("close-nested-dialog"), + ).not.toBeInTheDocument(); + + await fireEvent.pointerDown(document.body); + + expect(screen.queryByTestId("close-dialog")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/main.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/main.svelte new file mode 100644 index 00000000..808a1723 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/main.svelte @@ -0,0 +1,72 @@ + + + + + + + + + + +{#if open} + +
+ + + + {#if renderInput} + + + {/if} +
+
+{/if} + +
x
diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/nested-dialog.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/nested-dialog.svelte new file mode 100644 index 00000000..c1ca13f5 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/nested-dialog.svelte @@ -0,0 +1,18 @@ + + +{#if parentId == null} + + + +{:else} + +{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/nested-nested.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/nested-nested.svelte new file mode 100644 index 00000000..d7ebec7c --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/nested-nested.svelte @@ -0,0 +1,32 @@ + + + + {#snippet reference(ref, refProps)} + + {/snippet} + {#snippet content(close)} + + {#snippet reference(nestedRef, nestedRefProps)} + + {/snippet} + + {#snippet content(nestedClose)} + + {/snippet} + + + {/snippet} + diff --git a/packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts b/packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts index 1640e703..bd1bbec5 100644 --- a/packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts +++ b/packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts @@ -72,8 +72,6 @@ describe("useFloating", () => { floating: () => elements.floating, }); - console.log(floating.reference, floating.floating); - expect(floating.reference).toEqual(elements.reference); expect(floating.floating).toEqual(elements.floating); diff --git a/packages/floating-ui-svelte/vite.config.ts b/packages/floating-ui-svelte/vite.config.ts index 2860c75a..3eb5f5ed 100644 --- a/packages/floating-ui-svelte/vite.config.ts +++ b/packages/floating-ui-svelte/vite.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [svelte(), svelteTesting()], test: { - include: ["./test/{hooks, components}/*.ts"], + include: ["./test/**/*.test.ts"], setupFiles: ["./test/internal/setup.ts"], environment: "jsdom", }, diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte new file mode 100644 index 00000000..1f733af6 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/dialog.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/dialog.svelte new file mode 100644 index 00000000..75c0dd56 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/dialog.svelte @@ -0,0 +1,63 @@ + + + + {@render reference(referenceRef, ints.getReferenceProps())} + {#if open} + + +
+ {@render content(() => (open = false))} +
+
+
+ {/if} +
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/nested-dialog.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/nested-dialog.svelte new file mode 100644 index 00000000..db5a06c3 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/nested-dialog.svelte @@ -0,0 +1,20 @@ + + +{#if parentId == null} + + + +{:else} + +{/if} diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/nested-nested.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/nested-nested.svelte new file mode 100644 index 00000000..848008d2 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/nested-nested.svelte @@ -0,0 +1,34 @@ + + + + {#snippet reference(ref, refProps)} + + {/snippet} + {#snippet content(close)} + + {#snippet reference(nestedRef, nestedRefProps)} + + {/snippet} + + {#snippet content(nestedClose)} + + {/snippet} + + + {/snippet} + From 37718b4d23b0e50dfb90dd876049716f33d0e615 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 14 Jan 2025 20:58:54 -0500 Subject: [PATCH 21/87] focus manager tests wip --- packages/floating-ui-svelte/package.json | 1 + .../floating-focus-manager.svelte | 1 + .../components/combobox.svelte | 40 ++ .../components/dialog-fallback-ref.svelte | 47 ++ .../dialog-non-focusable-ref.svelte | 18 + .../{ => components}/dialog.svelte | 10 +- .../components/floating-fallback.svelte | 14 + .../components/keep-mounted.svelte | 34 ++ .../{ => components}/main.svelte | 6 +- .../components/mixed-mod-dialog.svelte | 69 +++ .../components/mixed-mod-main.svelte | 34 ++ .../components/mixed-mod-nested-dialog.svelte | 18 + .../{ => components}/nested-dialog.svelte | 4 +- .../{ => components}/nested-nested.svelte | 0 .../components/outside-nodes.svelte | 36 ++ .../components/toggle-disabled.svelte | 42 ++ .../floating-focus-manager.test.ts | 446 +++++++++++++++--- .../test/hooks/use-merge-refs.test.svelte.ts | 7 +- .../floating-ui-svelte/test/internal/setup.ts | 37 ++ .../floating-ui-svelte/test/internal/utils.ts | 70 +++ pnpm-lock.yaml | 8 + 21 files changed, 855 insertions(+), 87 deletions(-) create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/combobox.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog-fallback-ref.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog-non-focusable-ref.svelte rename packages/floating-ui-svelte/test/components/floating-focus-manager/{ => components}/dialog.svelte (75%) create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-fallback.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/keep-mounted.svelte rename packages/floating-ui-svelte/test/components/floating-focus-manager/{ => components}/main.svelte (84%) create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-dialog.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-main.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-nested-dialog.svelte rename packages/floating-ui-svelte/test/components/floating-focus-manager/{ => components}/nested-dialog.svelte (67%) rename packages/floating-ui-svelte/test/components/floating-focus-manager/{ => components}/nested-nested.svelte (100%) create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/outside-nodes.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/toggle-disabled.svelte create mode 100644 packages/floating-ui-svelte/test/internal/utils.ts diff --git a/packages/floating-ui-svelte/package.json b/packages/floating-ui-svelte/package.json index b219b27c..756e4947 100644 --- a/packages/floating-ui-svelte/package.json +++ b/packages/floating-ui-svelte/package.json @@ -38,6 +38,7 @@ "@testing-library/svelte": "^5.2.6", "@testing-library/user-event": "^14.5.2", "csstype": "^3.1.3", + "resize-observer-polyfill": "^1.5.1", "svelte": "^5.17.3", "typescript": "^5.7.2", "vite": "^6.0.6", diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index bba0d9ce..4f71fac2 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -186,6 +186,7 @@ ); const inertSupported = supportsInert(); + console.log("INERT SUPPORTED", inertSupported); const guards = $derived(inertSupported ? _guards : true); const useInert = $derived( !guards || (inertSupported && outsideElementsInert) diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/combobox.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/combobox.svelte new file mode 100644 index 00000000..cd434a5e --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/combobox.svelte @@ -0,0 +1,40 @@ + + + + (open = true)} /> + + + +{#if open} + +
+
+
+{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog-fallback-ref.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog-fallback-ref.svelte new file mode 100644 index 00000000..f5ac826b --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog-fallback-ref.svelte @@ -0,0 +1,47 @@ + + +{#if !removed} + +{/if} +{#if open} + + +
+ +
+
+
+{/if} + diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog-non-focusable-ref.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog-non-focusable-ref.svelte new file mode 100644 index 00000000..604482e8 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog-non-focusable-ref.svelte @@ -0,0 +1,18 @@ + + + + {#snippet reference(ref, props)} +
+ +
+ {/snippet} + + {#snippet content(handleClose)} + + {/snippet} +
diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/dialog.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog.svelte similarity index 75% rename from packages/floating-ui-svelte/test/components/floating-focus-manager/dialog.svelte rename to packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog.svelte index 877a4e83..a35140ed 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/dialog.svelte +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/dialog.svelte @@ -1,6 +1,6 @@ + + + + +
+
+
diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/keep-mounted.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/keep-mounted.svelte new file mode 100644 index 00000000..422337fa --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/keep-mounted.svelte @@ -0,0 +1,34 @@ + + + + +
+ +
+
+ diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/main.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/main.svelte similarity index 84% rename from packages/floating-ui-svelte/test/components/floating-focus-manager/main.svelte rename to packages/floating-ui-svelte/test/components/floating-focus-manager/components/main.svelte index 808a1723..197f6565 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/main.svelte +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/main.svelte @@ -1,7 +1,7 @@ + + + {@render reference?.(ref, ints.getReferenceProps())} + {#if open} + + +
+ {@render content?.(() => (controlledOpen = false))} +
+
+
+ {/if} + {@render sideChildren?.()} +
diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-main.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-main.svelte new file mode 100644 index 00000000..62a203a4 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-main.svelte @@ -0,0 +1,34 @@ + + + + {#snippet reference(ref, props)} + + {/snippet} + + {#snippet content(handleClose)} + + + {/snippet} + + {#snippet sideChildren()} + + {#snippet content(handleClose)} + + {/snippet} + + {/snippet} + diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-nested-dialog.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-nested-dialog.svelte new file mode 100644 index 00000000..b7e2b84f --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/mixed-mod-nested-dialog.svelte @@ -0,0 +1,18 @@ + + +{#if parentId == null} + + + +{:else} + +{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/nested-dialog.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/nested-dialog.svelte similarity index 67% rename from packages/floating-ui-svelte/test/components/floating-focus-manager/nested-dialog.svelte rename to packages/floating-ui-svelte/test/components/floating-focus-manager/components/nested-dialog.svelte index c1ca13f5..c259e50e 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/nested-dialog.svelte +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/nested-dialog.svelte @@ -1,8 +1,8 @@ + + (open = !open)} /> +
+
+ + +
+{#if open} + +
+
+{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/toggle-disabled.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/toggle-disabled.svelte new file mode 100644 index 00000000..f413d92f --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/toggle-disabled.svelte @@ -0,0 +1,42 @@ + + + + + +{#if open} + +
+
+
+{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index 2f10c9c0..6e4afc27 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -1,103 +1,405 @@ -import { fireEvent, render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { describe, expect, it, vi } from "vitest"; -import Main from "./main.svelte"; -import { tick } from "svelte"; -import NestedNested from "./nested-nested.svelte"; +import Main from "./components/main.svelte"; +import NestedNested from "./components/nested-nested.svelte"; +import DialogNonFocusableRef from "./components/dialog-non-focusable-ref.svelte"; +import { userEvent } from "@testing-library/user-event"; +import { sleep, testKbd } from "../../internal/utils.js"; +import DialogFallbackRef from "./components/dialog-fallback-ref.svelte"; +import Combobox from "./components/combobox.svelte"; +import FloatingFallback from "./components/floating-fallback.svelte"; +import MixedModMain from "./components/mixed-mod-main.svelte"; +import OutsideNodes from "./components/outside-nodes.svelte"; +import ToggleDisabled from "./components/toggle-disabled.svelte"; +import KeepMounted from "./components/keep-mounted.svelte"; -async function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +describe("initialFocus", () => { + it("handles numbers", async () => { + render(Main); -describe("FloatingFocusManager", () => { - describe("initialFocus", () => { - it("handles numbers", async () => { - render(Main); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); - await fireEvent.click(screen.getByTestId("reference")); - await sleep(20); + expect(screen.getByTestId("one")).toHaveFocus(); + const incrementButton = screen.getByTestId("increment-initialFocus"); - expect(screen.getByTestId("one")).toHaveFocus(); - const incrementButton = screen.getByTestId("increment-initialFocus"); + await fireEvent.click(incrementButton); + expect(screen.getByTestId("two")).not.toHaveFocus(); - await fireEvent.click(incrementButton); - expect(screen.getByTestId("two")).not.toHaveFocus(); + await fireEvent.click(incrementButton); + expect(screen.getByTestId("three")).not.toHaveFocus(); + }); + + it("handles elements", async () => { + render(Main, { initialFocus: "two" }); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); + + expect(screen.getByTestId("two")).toHaveFocus(); + }); + + it("respects autofocus", async () => { + render(Main, { renderInput: true }); + + await fireEvent.click(screen.getByTestId("reference")); + expect(screen.getByTestId("input")).toHaveFocus(); + }); +}); + +describe("returnFocus", () => { + it("respects true", async () => { + render(Main); - await fireEvent.click(incrementButton); - expect(screen.getByTestId("three")).not.toHaveFocus(); - }); + screen.getByTestId("reference").focus(); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); - it("handles elements", async () => { - render(Main, { initialFocus: "two" }); - await fireEvent.click(screen.getByTestId("reference")); - await sleep(20); + expect(screen.getByTestId("one")).toHaveFocus(); + + await fireEvent.click(screen.getByTestId("three")); + expect(screen.getByTestId("reference")).toHaveFocus(); + }); - expect(screen.getByTestId("two")).toHaveFocus(); - }); + it("respects false", async () => { + render(Main, { returnFocus: false }); - it("respects autofocus", async () => { - render(Main, { renderInput: true }); + screen.getByTestId("reference").focus(); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); - await fireEvent.click(screen.getByTestId("reference")); - expect(screen.getByTestId("input")).toHaveFocus(); - }); + expect(screen.getByTestId("one")).toHaveFocus(); + + await fireEvent.click(screen.getByTestId("three")); + expect(screen.getByTestId("reference")).not.toHaveFocus(); }); - describe("returnFocus", () => { - it("respects true", async () => { - render(Main); + it("respects ref", async () => { + render(Main, { returnFocus: "inputRef" }); + screen.getByTestId("reference").focus(); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(20); + + expect(screen.getByTestId("one")).toHaveFocus(); + + await fireEvent.click(screen.getByTestId("three")); + expect(screen.getByTestId("reference")).not.toHaveFocus(); + expect(screen.getByTestId("focus-target")).toHaveFocus(); + }); + + it("return to reference for nested", async () => { + render(NestedNested); + + screen.getByTestId("open-dialog").focus(); + await userEvent.keyboard(testKbd.ENTER); + + await fireEvent.click(screen.getByTestId("open-nested-dialog")); + + expect(screen.queryByTestId("close-nested-dialog")).toBeInTheDocument(); + + await userEvent.click(document.body); + + expect(screen.queryByTestId("close-nested-dialog")).not.toBeInTheDocument(); + + expect(screen.queryByTestId("close-dialog")).toBeInTheDocument(); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.queryByTestId("close-dialog")).not.toBeInTheDocument(); + expect(screen.getByTestId("open-dialog")).toHaveFocus(); + }); + + it("returns to the first focusable descendent of the reference if the reference is not focusable", async () => { + render(DialogNonFocusableRef); + + screen.getByTestId("open-dialog").focus(); + await userEvent.keyboard(testKbd.ENTER); + + expect(screen.queryByTestId("close-dialog")).toBeInTheDocument(); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.queryByTestId("close-dialog")).not.toBeInTheDocument(); + + expect(screen.getByTestId("open-dialog")).toHaveFocus(); + }); + + it("preserves tabbable context next to reference element if removed (modal)", async () => { + render(DialogFallbackRef); + + await fireEvent.click(screen.getByTestId("reference")); + await fireEvent.click(screen.getByTestId("remove")); + await userEvent.tab(); + expect(screen.getByTestId("fallback")).toHaveFocus(); + }); + + it("preserves tabbable context next to reference element if removed (non-modal)", async () => { + render(DialogFallbackRef, { modal: false }); + + await fireEvent.click(screen.getByTestId("reference")); + await fireEvent.click(screen.getByTestId("remove")); + await userEvent.tab(); + expect(screen.getByTestId("fallback")).toHaveFocus(); + }); +}); + +describe("guards", () => { + it("respects true", async () => { + render(Main, { guards: true }); + + await fireEvent.click(screen.getByTestId("reference")); + + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + + expect(document.body).not.toHaveFocus(); + }); + it("respects false", async () => { + render(Main, { guards: false }); + await fireEvent.click(screen.getByTestId("reference")); + + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + + expect(document.activeElement).toHaveAttribute("data-floating-ui-inert"); + }); +}); + +describe("modal", () => { + it("respects true", async () => { + render(Main, { modal: true }); + await fireEvent.click(screen.getByTestId("reference")); + + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("two")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("three")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); - screen.getByTestId("reference").focus(); - await fireEvent.click(screen.getByTestId("reference")); - await sleep(20); + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("three")).toHaveFocus()); - expect(screen.getByTestId("one")).toHaveFocus(); + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("two")).toHaveFocus()); - await fireEvent.click(screen.getByTestId("three")); - expect(screen.getByTestId("reference")).toHaveFocus(); - }); + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); - it("respects false", async () => { - render(Main, { returnFocus: false }); + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("three")).toHaveFocus()); - screen.getByTestId("reference").focus(); - await fireEvent.click(screen.getByTestId("reference")); - await sleep(20); + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); + }); + + it("respects false", async () => { + render(Main, { modal: false }); + await fireEvent.click(screen.getByTestId("reference")); + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("two")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("three")).toHaveFocus()); + + await userEvent.tab(); + + // Focus leaving the floating element closes it. + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + + expect(screen.getByTestId("last")).toHaveFocus(); + }); + + it("false - shift tabbing does not trap focus when reference is in order", async () => { + render(Main, { modal: false, order: ["reference", "content"] }); + await fireEvent.click(screen.getByTestId("reference")); + + await userEvent.tab(); + await userEvent.tab({ shift: true }); + await userEvent.tab({ shift: true }); + + await waitFor(() => + expect(screen.queryByRole("dialog")).toBeInTheDocument(), + ); + }); + + it("true - combobox hide all other nodes with aria-hidden", async () => { + render(Combobox); + + await fireEvent.focus(screen.getByTestId("reference")); + + expect(screen.getByTestId("reference")).not.toHaveAttribute("aria-hidden"); + expect(screen.getByTestId("floating")).not.toHaveAttribute("aria-hidden"); + expect(screen.getByTestId("btn-1")).toHaveAttribute("aria-hidden"); + expect(screen.getByTestId("btn-2")).toHaveAttribute("aria-hidden"); + }); + + it("true - combobox hide all other nodes with inert when outsideElementsInert=true", async () => { + render(Combobox, { outsideElementsInert: true }); + + await fireEvent.focus(screen.getByTestId("reference")); + + expect(screen.getByTestId("reference")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("floating")).not.toHaveAttribute("inert"); + await waitFor(() => + expect(screen.getByTestId("btn-1")).toHaveAttribute("inert"), + ); + await waitFor(() => + expect(screen.getByTestId("btn-2")).toHaveAttribute("inert"), + ); + }); + + it("false - comboboxes do not hide all other nodes", async () => { + render(Combobox, { modal: false }); + + await fireEvent.focus(screen.getByTestId("reference")); + + expect(screen.getByTestId("reference")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("floating")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("btn-1")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("btn-2")).not.toHaveAttribute("inert"); + }); + + it("falls back to the floating element when it has no tabbable content", async () => { + render(FloatingFallback); + + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + }); + + it("correctly handles mixed modality and nesting ", async () => { + render(MixedModMain); + + await userEvent.click(screen.getByTestId("open-dialog")); + await userEvent.click(screen.getByTestId("open-nested-dialog")); + + expect(screen.queryByTestId("close-dialog")).toBeInTheDocument(); + expect(screen.queryByTestId("close-nested-dialog")).toBeInTheDocument(); + }); + + it("true - applies aria-hidden to outside nodes", async () => { + render(OutsideNodes); + await fireEvent.click(screen.getByTestId("reference")); + + expect(screen.getByTestId("reference")).toHaveAttribute( + "aria-hidden", + "true", + ); + expect(screen.getByTestId("floating")).not.toHaveAttribute("aria-hidden"); + expect(screen.getByTestId("aria-live")).not.toHaveAttribute("aria-hidden"); + expect(screen.getByTestId("btn-1")).toHaveAttribute("aria-hidden", "true"); + expect(screen.getByTestId("btn-2")).toHaveAttribute("aria-hidden", "true"); + + await fireEvent.click(screen.getByTestId("reference")); + + expect(screen.getByTestId("reference")).not.toHaveAttribute("aria-hidden"); + expect(screen.getByTestId("aria-live")).not.toHaveAttribute("aria-hidden"); + expect(screen.getByTestId("btn-1")).not.toHaveAttribute("aria-hidden"); + expect(screen.getByTestId("btn-2")).not.toHaveAttribute("aria-hidden"); + }); + + it("true - applies inert to outside nodes when outsideElementsInert=true", async () => { + render(OutsideNodes, { outsideElementsInert: true }); + await fireEvent.click(screen.getByTestId("reference")); + + expect(screen.getByTestId("reference")).toHaveAttribute("inert"); + expect(screen.getByTestId("floating")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("aria-live")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("btn-1")).toHaveAttribute("inert"); + expect(screen.getByTestId("btn-2")).toHaveAttribute("inert"); + + await fireEvent.click(screen.getByTestId("reference")); + + expect(screen.getByTestId("reference")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("aria-live")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("btn-1")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("btn-2")).not.toHaveAttribute("inert"); + }); + + it("false - does not apply inert to outside nodes", async () => { + render(OutsideNodes, { modal: false }); + await fireEvent.click(screen.getByTestId("reference")); + + expect(screen.getByTestId("floating")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("aria-live")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("btn-1")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("btn-2")).not.toHaveAttribute("inert"); + expect(screen.getByTestId("reference")).toHaveAttribute( + "data-floating-ui-inert", + ); + expect(screen.getByTestId("btn-1")).toHaveAttribute( + "data-floating-ui-inert", + ); + expect(screen.getByTestId("btn-2")).toHaveAttribute( + "data-floating-ui-inert", + ); + + await fireEvent.click(screen.getByTestId("reference")); + + expect(screen.getByTestId("reference")).not.toHaveAttribute( + "data-floating-ui-inert", + ); + expect(screen.getByTestId("btn-1")).not.toHaveAttribute( + "data-floating-ui-inert", + ); + expect(screen.getByTestId("btn-2")).not.toHaveAttribute( + "data-floating-ui-inert", + ); + }); +}); + +describe("disabled", () => { + it("respects true -> false", async () => { + render(ToggleDisabled); + + await fireEvent.click(screen.getByTestId("reference")); + expect(screen.getByTestId("floating")).not.toHaveFocus(); + await waitFor(() => + expect(screen.getByTestId("floating")).not.toHaveFocus(), + ); + await fireEvent.click(screen.getByTestId("toggle")); + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + }); + + it("respects false", async () => { + render(ToggleDisabled, { disabled: false }); + + await fireEvent.click(screen.getByTestId("reference")); + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + }); - expect(screen.getByTestId("one")).toHaveFocus(); + it("supports keepMounted behavior", async () => { + render(KeepMounted); - await fireEvent.click(screen.getByTestId("three")); - expect(screen.getByTestId("reference")).not.toHaveFocus(); - }); + expect(screen.getByTestId("floating")).not.toHaveFocus(); - it("respects ref", async () => { - render(Main, { returnFocus: "inputRef" }); - screen.getByTestId("reference").focus(); - await fireEvent.click(screen.getByTestId("reference")); - await sleep(20); + await fireEvent.click(screen.getByTestId("reference")); - expect(screen.getByTestId("one")).toHaveFocus(); + await waitFor(() => expect(screen.getByTestId("child")).toHaveFocus()); - await fireEvent.click(screen.getByTestId("three")); - expect(screen.getByTestId("reference")).not.toHaveFocus(); - expect(screen.getByTestId("focus-target")).toHaveFocus(); - }); + await userEvent.tab(); - it("always returns to the reference for nested elements", async () => { - render(NestedNested); - await fireEvent.click(screen.getByTestId("open-dialog")); - await fireEvent.click(screen.getByTestId("open-nested-dialog")); + await waitFor(() => expect(screen.getByTestId("after")).toHaveFocus()); - expect(screen.queryByTestId("close-nested-dialog")).toBeInTheDocument(); + await userEvent.tab({ shift: true }); - await fireEvent.pointerDown(document.body); + await fireEvent.click(screen.getByTestId("reference")); - expect( - screen.queryByTestId("close-nested-dialog"), - ).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId("child")).toHaveFocus()); - await fireEvent.pointerDown(document.body); + await userEvent.keyboard(testKbd.ESCAPE); - expect(screen.queryByTestId("close-dialog")).not.toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); }); }); diff --git a/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts b/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts index 6a145db3..6e4737cd 100644 --- a/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts +++ b/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts @@ -1,10 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { useFloating } from "../../src/index.js"; import { withRunes } from "../internal/with-runes.svelte.js"; -import { - useMergeRefs, - type BoxedRef, -} from "../../src/hooks/use-merge-refs.svelte.js"; +import { useMergeRefs } from "../../src/hooks/use-merge-refs.svelte.js"; describe("useMergeRefs", () => { vi.mock(import("svelte"), async (importOriginal) => { @@ -20,7 +17,7 @@ describe("useMergeRefs", () => { withRunes(() => { const ref1 = useFloating(); const ref2 = useFloating(); - const ref3: BoxedRef = $state({ current: null }); + const ref3 = $state({ current: null }); const mergedRef = useMergeRefs([ref1, ref2, ref3]); expect(mergedRef.current).toBe(null); diff --git a/packages/floating-ui-svelte/test/internal/setup.ts b/packages/floating-ui-svelte/test/internal/setup.ts index f149f27a..8bc2e4bb 100644 --- a/packages/floating-ui-svelte/test/internal/setup.ts +++ b/packages/floating-ui-svelte/test/internal/setup.ts @@ -1 +1,38 @@ import "@testing-library/jest-dom/vitest"; + +HTMLElement.prototype.inert = true; + +global.ResizeObserver = require("resize-observer-polyfill"); + +class PointerEvent extends MouseEvent { + public isPrimary: boolean; + public pointerId: number; + public pointerType: string; + public height: number; + public width: number; + public tiltX: number; + public tiltY: number; + public twist: number; + public pressure: number; + public tangentialPressure: number; + + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + + // Using defaults from W3C specs: + // https://w3c.github.io/pointerevents/#pointerevent-interface + this.isPrimary = params.isPrimary ?? false; + this.pointerId = params.pointerId ?? 0; + this.pointerType = params.pointerType ?? ""; + this.width = params.width ?? 1; + this.height = params.height ?? 1; + this.tiltX = params.tiltX ?? 0; + this.tiltY = params.tiltY ?? 0; + this.twist = params.twist ?? 0; + this.pressure = params.pressure ?? 0; + this.tangentialPressure = params.tangentialPressure ?? 0; + } +} + +global.PointerEvent = + global.PointerEvent ?? (PointerEvent as typeof globalThis.PointerEvent); diff --git a/packages/floating-ui-svelte/test/internal/utils.ts b/packages/floating-ui-svelte/test/internal/utils.ts new file mode 100644 index 00000000..17f8e22b --- /dev/null +++ b/packages/floating-ui-svelte/test/internal/utils.ts @@ -0,0 +1,70 @@ +function sleep(ms = 0) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type KbdKeys = keyof typeof kbd; +const kbd = { + ALT: "Alt", + ARROW_DOWN: "ArrowDown", + ARROW_LEFT: "ArrowLeft", + ARROW_RIGHT: "ArrowRight", + ARROW_UP: "ArrowUp", + BACKSPACE: "Backspace", + CAPS_LOCK: "CapsLock", + CONTROL: "Control", + DELETE: "Delete", + END: "End", + ENTER: "Enter", + ESCAPE: "Escape", + F1: "F1", + F10: "F10", + F11: "F11", + F12: "F12", + F2: "F2", + F3: "F3", + F4: "F4", + F5: "F5", + F6: "F6", + F7: "F7", + F8: "F8", + F9: "F9", + HOME: "Home", + META: "Meta", + PAGE_DOWN: "PageDown", + PAGE_UP: "PageUp", + SHIFT: "Shift", + SPACE: " ", + TAB: "Tab", + CTRL: "Control", + ASTERISK: "*", + a: "a", + P: "P", + A: "A", + p: "p", + n: "n", + j: "j", + k: "k", +}; + +function getTestKbd() { + const initTestKbd: Record = Object.entries(kbd).reduce( + (acc, [key, value]) => { + acc[key as KbdKeys] = `{${value}}`; + return acc; + }, + {} as Record, + ); + + return { + ...initTestKbd, + SHIFT_TAB: `{Shift>}{${kbd.TAB}}`, + }; +} + +/** + * A wrapper around the internal kbd object to make it easier to use in tests + * which require the key names to be wrapped in curly braces. + */ +const testKbd = getTestKbd(); + +export { testKbd, sleep }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e151f233..360fed7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: csstype: specifier: ^3.1.3 version: 3.1.3 + resize-observer-polyfill: + specifier: ^1.5.1 + version: 1.5.1 svelte: specifier: ^5.17.3 version: 5.17.3 @@ -1810,6 +1813,9 @@ packages: regex@5.1.1: resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -3916,6 +3922,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 + resize-observer-polyfill@1.5.1: {} + resolve-from@5.0.0: {} resolve@1.22.10: From 51a8adc883ea8e1b303203f263311160f7e0cbb6 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 15 Jan 2025 11:09:04 -0500 Subject: [PATCH 22/87] we are cooking --- .../floating-focus-manager.svelte | 13 +- .../floating-portal/floating-portal.svelte | 8 +- .../src/components/focus-guard.svelte | 10 +- .../internal/get-floating-focus-element.ts | 4 +- .../non-modal-floating-portal.svelte | 38 ++++++ .../floating-focus-manager.test.ts | 115 +++++++++++++++++- .../src/routes/(inner)/sink/+page.svelte | 48 +++++++- 7 files changed, 215 insertions(+), 21 deletions(-) create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/non-modal-floating-portal.svelte diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index 4f71fac2..d3380398 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -186,7 +186,6 @@ ); const inertSupported = supportsInert(); - console.log("INERT SUPPORTED", inertSupported); const guards = $derived(inertSupported ? _guards : true); const useInert = $derived( !guards || (inertSupported && outsideElementsInert) @@ -203,8 +202,6 @@ const isInsidePortal = portalContext != null; - console.log("isInsidePortal", isInsidePortal); - const floatingFocusElement = $derived( getFloatingFocusElement(context.floating) ); @@ -216,7 +213,7 @@ } function getTabbableElements(container?: Element) { - const content = getTabbableContent(); + const content = getTabbableContent(container); return order .map((type) => { if (context.domReference && type === "reference") { @@ -398,11 +395,11 @@ const mergedBeforeGuardRef = useMergeRefs([ beforeGuardRef, - portalContext?.beforeOutsideRef, + portalContext?.beforeInsideRef, ]); const mergedAfterGuardRef = useMergeRefs([ afterGuardRef, - portalContext?.afterOutsideRef, + portalContext?.beforeInsideRef, ]); $effect(() => { @@ -696,7 +693,7 @@ {#if shouldRenderGuards} { if (modal) { @@ -730,7 +727,7 @@ will have a dismiss button. {@render DismissButton({ ref: endDismissButtonRef })} {#if shouldRenderGuards} { if (modal) { diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index f7a2a287..78864a10 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -1,5 +1,5 @@ @@ -44,6 +44,7 @@ let { ref = $bindable(null), children, + type, ...rest }: FocusGuardProps = $props(); @@ -69,6 +70,7 @@ "aria-hidden": role ? undefined : "true", [createAttribute("focus-guard")]: "", style: HIDDEN_STYLES_STRING, + "data-type": type, }); diff --git a/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts index fceaa818..21442e62 100644 --- a/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts +++ b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts @@ -3,9 +3,7 @@ export const FOCUSABLE_ATTRIBUTE = "data-floating-ui-focusable"; export function getFloatingFocusElement( floatingElement: HTMLElement | null | undefined, ): HTMLElement | null { - if (!floatingElement) { - return null; - } + if (!floatingElement) return null; // Try to find the element that has `{...getFloatingProps()}` spread on it. // This indicates the floating element is acting as a positioning wrapper, and // so focus should be managed on the child element with the event handlers and diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/non-modal-floating-portal.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/non-modal-floating-portal.svelte new file mode 100644 index 00000000..2ea077b0 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/non-modal-floating-portal.svelte @@ -0,0 +1,38 @@ + + + + + +{#if open} + + +
+ +
+
+
+{/if} + diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index 6e4afc27..e7d1d971 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, test, vi } from "vitest"; import Main from "./components/main.svelte"; import NestedNested from "./components/nested-nested.svelte"; import DialogNonFocusableRef from "./components/dialog-non-focusable-ref.svelte"; @@ -12,6 +12,8 @@ import MixedModMain from "./components/mixed-mod-main.svelte"; import OutsideNodes from "./components/outside-nodes.svelte"; import ToggleDisabled from "./components/toggle-disabled.svelte"; import KeepMounted from "./components/keep-mounted.svelte"; +import NonModalFloatingPortal from "./components/non-modal-floating-portal.svelte"; +import { tick } from "svelte"; describe("initialFocus", () => { it("handles numbers", async () => { @@ -403,3 +405,114 @@ describe("disabled", () => { await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); }); }); + +describe("order", () => { + it("handles [reference, content]", async () => { + render(Main, { order: ["reference", "content"] }); + await fireEvent.click(screen.getByTestId("reference")); + + await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("two")).toHaveFocus()); + }); + + it("handles [floating, content]", async () => { + render(Main, { order: ["floating", "content"] }); + + await fireEvent.click(screen.getByTestId("reference")); + + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("two")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("three")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("three")).toHaveFocus()); + + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("two")).toHaveFocus()); + + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); + + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + }); + + it("handles [reference, floating, content]", async () => { + render(Main, { order: ["reference", "floating", "content"] }); + await fireEvent.click(screen.getByTestId("reference")); + + await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("one")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("two")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("three")).toHaveFocus()); + + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); + + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("three")).toHaveFocus()); + + await userEvent.tab({ shift: true }); + await userEvent.tab({ shift: true }); + await userEvent.tab({ shift: true }); + await userEvent.tab({ shift: true }); + + await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); + }); +}); + +describe("non-modal + FloatingPortal", () => { + it("focuses inside element, tabbing out focuses last document element", async () => { + render(NonModalFloatingPortal); + await userEvent.click(screen.getByTestId("reference")); + + await waitFor(() => expect(screen.getByTestId("inside")).toHaveFocus()); + + await userEvent.tab(); + + await waitFor(() => + expect(screen.queryByTestId("floating")).not.toBeInTheDocument(), + ); + await waitFor(() => expect(screen.getByTestId("last")).toHaveFocus()); + }); + + it("handles order: [reference, content] focuses reference, then inside, then, last document element", async () => { + render(NonModalFloatingPortal, { order: ["reference", "content"] }); + + await userEvent.click(screen.getByTestId("reference")); + + await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); + + await userEvent.tab(); + + await waitFor(() => expect(screen.getByTestId("inside")).toHaveFocus()); + + await userEvent.tab(); + + await waitFor(() => expect(screen.getByTestId("last")).toHaveFocus()); + }); +}); diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte index 1f733af6..382e8877 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -1,5 +1,49 @@ - + + +{#if open} + + +
+ +
+
+
+{/if} + From 13181cc15d449cf4f9948dbf8caae30d1922435a Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 15 Jan 2025 12:05:06 -0500 Subject: [PATCH 23/87] alrighty we are back in the kitchen --- .../floating-focus-manager.svelte | 3 ++- .../non-modal-floating-portal.svelte | 17 +++++++++----- .../floating-focus-manager.test.ts | 22 +++++++++++++++++++ .../src/routes/(inner)/sink/+page.svelte | 11 ++++++---- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index d3380398..0920d6cd 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -399,7 +399,7 @@ ]); const mergedAfterGuardRef = useMergeRefs([ afterGuardRef, - portalContext?.beforeInsideRef, + portalContext?.afterInsideRef, ]); $effect(() => { @@ -709,6 +709,7 @@ if (isOutsideEvent(event, portalContext.portalNode)) { const nextTabbable = getNextTabbable() || context.domReference; + nextTabbable?.focus(); } else { portalContext.beforeOutsideRef.current?.focus(); diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/non-modal-floating-portal.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/non-modal-floating-portal.svelte index 2ea077b0..ef1dc0c0 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/non-modal-floating-portal.svelte +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/non-modal-floating-portal.svelte @@ -20,19 +20,26 @@ }); - + + - {#if open} -
- +
+ +
{/if} - + + diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index e7d1d971..6be2b484 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -506,6 +506,28 @@ describe("non-modal + FloatingPortal", () => { await userEvent.click(screen.getByTestId("reference")); await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); + await sleep(20); + await userEvent.tab(); + + await waitFor(() => expect(screen.getByTestId("inside")).toHaveFocus()); + + await userEvent.tab(); + + await waitFor(() => expect(screen.getByTestId("last")).toHaveFocus()); + }); + + it("handles order: [reference, floating, content] focuses reference, then floating, then inside, then, last document element", async () => { + render(NonModalFloatingPortal, { + order: ["reference", "floating", "content"], + }); + + await userEvent.click(screen.getByTestId("reference")); + await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); + await sleep(20); + + await userEvent.tab(); + + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); await userEvent.tab(); diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte index 382e8877..96c25890 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -24,7 +24,8 @@ }); - + + +
    {@render children?.()}
+
+
+
+ {/if} + diff --git a/packages/floating-ui-svelte/test/components/navigation/navigation-sub-item.svelte b/packages/floating-ui-svelte/test/components/navigation/navigation-sub-item.svelte new file mode 100644 index 00000000..eff06244 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/navigation/navigation-sub-item.svelte @@ -0,0 +1,16 @@ + + + + {label} + diff --git a/packages/floating-ui-svelte/test/components/navigation/navigation.svelte b/packages/floating-ui-svelte/test/components/navigation/navigation.svelte new file mode 100644 index 00000000..89ec72f6 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/navigation/navigation.svelte @@ -0,0 +1,9 @@ + + + diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/main.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/main.svelte new file mode 100644 index 00000000..aa345fbc --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/main.svelte @@ -0,0 +1,19 @@ + + +

Navigation

+
+ + + + + + + + + +
diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation-item.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation-item.svelte new file mode 100644 index 00000000..c1692e0a --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation-item.svelte @@ -0,0 +1,100 @@ + + + +
  • + + {label} + {#if hasChildren} + + {/if} + +
  • + {#if open} + + +
    + +
      {@render children?.()}
    +
    +
    +
    + {/if} +
    diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation-sub-item.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation-sub-item.svelte new file mode 100644 index 00000000..eff06244 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation-sub-item.svelte @@ -0,0 +1,16 @@ + + + + {label} + diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation.svelte new file mode 100644 index 00000000..89ec72f6 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(navigation)/navigation.svelte @@ -0,0 +1,9 @@ + + + diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte index 96c25890..a399e1c7 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -1,31 +1,33 @@ - - +
    + +
    {/if} - - + --> From 41249501aa1dccc74bcaf56ed7f90a247e817e50 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 15 Jan 2025 16:22:07 -0500 Subject: [PATCH 26/87] cleanup unused --- .../floating-focus-manager.svelte | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index 11cc7e48..e011976f 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -205,17 +205,6 @@ const isInsidePortal = portalContext != null; - const isFocusInsideFloatingTree = $derived( - contains(context.floating, reactiveActiveElement.current) || - (tree && - getChildren(tree.nodes, nodeId).some((node) => - contains( - node.context?.floating, - reactiveActiveElement.current - ) - )) - ); - $effect(() => { if (reactiveActiveElement.current === null) return; prevActiveElement = reactiveActiveElement.current; From d499f77cacbcedc7483eaed9ac7997fa912e22aa Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 15 Jan 2025 17:08:13 -0500 Subject: [PATCH 27/87] test failing --- packages/floating-ui-svelte/package.json | 1 + packages/floating-ui-svelte/src/app.d.ts | 7 + .../src/components/floating-overlay.svelte | 124 ++++++++++++++++++ packages/floating-ui-svelte/src/index.ts | 3 + .../test/components/button.svelte | 22 ++++ .../test/components/drawer/drawer.svelte | 94 +++++++++++++ .../test/components/drawer/main.svelte | 21 +++ .../floating-focus-manager.test.ts | 69 ++++++++++ pnpm-lock.yaml | 6 + .../package.json | 1 + .../(inner)/sink/(drawer)/drawer.svelte | 93 +++++++++++++ .../routes/(inner)/sink/(drawer)/main.svelte | 21 +++ .../src/routes/(inner)/sink/+page.svelte | 2 +- .../src/routes/(inner)/sink/button.svelte | 23 ++++ 14 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 packages/floating-ui-svelte/src/app.d.ts create mode 100644 packages/floating-ui-svelte/src/components/floating-overlay.svelte create mode 100644 packages/floating-ui-svelte/test/components/button.svelte create mode 100644 packages/floating-ui-svelte/test/components/drawer/drawer.svelte create mode 100644 packages/floating-ui-svelte/test/components/drawer/main.svelte create mode 100644 sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/drawer.svelte create mode 100644 sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/main.svelte create mode 100644 sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/button.svelte diff --git a/packages/floating-ui-svelte/package.json b/packages/floating-ui-svelte/package.json index 756e4947..640f647e 100644 --- a/packages/floating-ui-svelte/package.json +++ b/packages/floating-ui-svelte/package.json @@ -37,6 +37,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/svelte": "^5.2.6", "@testing-library/user-event": "^14.5.2", + "clsx": "^2.1.1", "csstype": "^3.1.3", "resize-observer-polyfill": "^1.5.1", "svelte": "^5.17.3", diff --git a/packages/floating-ui-svelte/src/app.d.ts b/packages/floating-ui-svelte/src/app.d.ts new file mode 100644 index 00000000..89f0ac89 --- /dev/null +++ b/packages/floating-ui-svelte/src/app.d.ts @@ -0,0 +1,7 @@ +declare global { + var fui_lock_count: { current: number }; + // biome-ignore lint/complexity/noBannedTypes: + var fui_lock_cleanup: { current: Function }; +} + +export {}; diff --git a/packages/floating-ui-svelte/src/components/floating-overlay.svelte b/packages/floating-ui-svelte/src/components/floating-overlay.svelte new file mode 100644 index 00000000..8cdf2991 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-overlay.svelte @@ -0,0 +1,124 @@ + + + + +
    + {@render children?.()} +
    diff --git a/packages/floating-ui-svelte/src/index.ts b/packages/floating-ui-svelte/src/index.ts index d059c09f..6c6967fe 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -17,6 +17,9 @@ export * from "./components/floating-tree/hooks.svelte.js"; export * from "./components/floating-focus-manager/floating-focus-manager.svelte"; export { default as FloatingFocusManager } from "./components/floating-focus-manager/floating-focus-manager.svelte"; +export * from "./components/floating-overlay.svelte"; +export { default as FloatingOverlay } from "./components/floating-overlay.svelte"; + /** * Hooks */ diff --git a/packages/floating-ui-svelte/test/components/button.svelte b/packages/floating-ui-svelte/test/components/button.svelte new file mode 100644 index 00000000..00bc5f50 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/button.svelte @@ -0,0 +1,22 @@ + + + diff --git a/packages/floating-ui-svelte/test/components/drawer/drawer.svelte b/packages/floating-ui-svelte/test/components/drawer/drawer.svelte new file mode 100644 index 00000000..98eb5d7b --- /dev/null +++ b/packages/floating-ui-svelte/test/components/drawer/drawer.svelte @@ -0,0 +1,94 @@ + + +{#snippet Content()} + +
    + {@render content({ + labelId, + descriptionId, + close: () => (open = false), + })} +
    +
    +{/snippet} + +{@render reference?.(ref, ints.getReferenceProps())} + +{#if open} + + {#if modal} + + {@render Content()} + + {:else} + {@render Content()} + {/if} + +{/if} diff --git a/packages/floating-ui-svelte/test/components/drawer/main.svelte b/packages/floating-ui-svelte/test/components/drawer/main.svelte new file mode 100644 index 00000000..c579402c --- /dev/null +++ b/packages/floating-ui-svelte/test/components/drawer/main.svelte @@ -0,0 +1,21 @@ + + +

    Drawer

    +
    + + {#snippet reference(ref, props)} + + {/snippet} + {#snippet content({ labelId, descriptionId, close })} +

    Title

    +

    Description

    + + {/snippet} +
    + +
    +
    diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index 43784a8a..4dc11283 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -14,6 +14,7 @@ import ToggleDisabled from "./components/toggle-disabled.svelte"; import KeepMounted from "./components/keep-mounted.svelte"; import NonModalFloatingPortal from "./components/non-modal-floating-portal.svelte"; import Navigation from "../navigation/main.svelte"; +import Drawer from "../drawer/main.svelte"; describe("initialFocus", () => { it("handles numbers", async () => { @@ -583,4 +584,72 @@ describe("Navigation", () => { await waitFor(() => expect(screen.getByText("Product")).toHaveFocus()); }); + + it("does not re-open after closing via escape key", async () => { + render(Navigation); + await userEvent.hover(screen.getByText("Product")); + await userEvent.keyboard(testKbd.ESCAPE); + expect(screen.queryByText("Link 1")).not.toBeInTheDocument(); + }); + + it("closes when unhovering floating element even when focus is inside it", async () => { + render(Navigation); + await userEvent.hover(screen.getByText("Product")); + await userEvent.click(screen.getByTestId("subnavigation")); + await userEvent.unhover(screen.getByTestId("subnavigation")); + await userEvent.hover(screen.getByText("Product")); + await userEvent.unhover(screen.getByText("Product")); + expect(screen.queryByTestId("subnavigation")).not.toBeInTheDocument(); + }); +}); + +describe("Drawer", () => { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: true, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + it("does not close when clicking another button outside", async () => { + render(Drawer); + + await userEvent.click(screen.getByText("My button")); + expect(screen.queryByText("Close")).toBeInTheDocument(); + await userEvent.click(screen.getByText("Next button")); + await waitFor(() => + expect(screen.queryByText("Close")).toBeInTheDocument(), + ); + }); + + it("closeOnFocusOut=false - does not close when tabbing out", async () => { + render(Drawer); + + await userEvent.click(screen.getByText("My button")); + await sleep(20); + expect(screen.queryByText("Close")).toBeInTheDocument(); + await userEvent.keyboard(testKbd.TAB); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByText("Next button")), + ); + expect(screen.queryByText("Close")).toBeInTheDocument(); + }); + + it.only("returns focus when tabbing out then back to close button", async () => { + render(Drawer); + + await userEvent.click(screen.getByText("My button")); + await sleep(20); + expect(screen.queryByText("Close")).toBeInTheDocument(); + await userEvent.keyboard(testKbd.TAB); + await waitFor(() => expect(screen.getByText("Next button")).toHaveFocus()); + await userEvent.keyboard("{Shift>}{Tab}{/Shift}"); + await sleep(20); + await waitFor(() => expect(screen.getByText("Close")).toHaveFocus()); + await userEvent.click(screen.getByText("Close")); + await waitFor(() => expect(screen.getByText("My button")).toHaveFocus()); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 360fed7e..ed06af54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) + clsx: + specifier: ^2.1.1 + version: 2.1.1 csstype: specifier: ^3.1.3 version: 3.1.3 @@ -87,6 +90,9 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) + clsx: + specifier: ^2.1.1 + version: 2.1.1 focus-trap: specifier: ^7.6.2 version: 7.6.2 diff --git a/sites/floating-ui-svelte.vercel.app/package.json b/sites/floating-ui-svelte.vercel.app/package.json index 9a9f5e9a..a63261ce 100644 --- a/sites/floating-ui-svelte.vercel.app/package.json +++ b/sites/floating-ui-svelte.vercel.app/package.json @@ -15,6 +15,7 @@ "@sveltejs/kit": "^2.15.1", "@sveltejs/vite-plugin-svelte": "^5.0.3", "autoprefixer": "^10.4.20", + "clsx": "^2.1.1", "focus-trap": "^7.6.2", "lucide-svelte": "^0.469.0", "pagefind": "^1.3.0", diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/drawer.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/drawer.svelte new file mode 100644 index 00000000..ae3d1cd9 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/drawer.svelte @@ -0,0 +1,93 @@ + + +{#snippet Content()} + +
    + {@render content({ + labelId, + descriptionId, + close: () => (open = false), + })} +
    +
    +{/snippet} + +{@render reference?.(ref, ints.getReferenceProps())} + +{#if open} + + {#if modal} + + {@render Content()} + + {:else} + {@render Content()} + {/if} + +{/if} diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/main.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/main.svelte new file mode 100644 index 00000000..c579402c --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/main.svelte @@ -0,0 +1,21 @@ + + +

    Drawer

    +
    + + {#snippet reference(ref, props)} + + {/snippet} + {#snippet content({ labelId, descriptionId, close })} +

    Title

    +

    Description

    + + {/snippet} +
    + +
    +
    diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte index a399e1c7..27b3a2b2 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -22,7 +22,7 @@ // open = v; // }, // }); - import Main from "./(navigation)/main.svelte"; + import Main from "./(drawer)/main.svelte";
    diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/button.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/button.svelte new file mode 100644 index 00000000..c956b1e2 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/button.svelte @@ -0,0 +1,23 @@ + + + From c9d573006fc76a447d5151899124f002574d4d93 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 15 Jan 2025 19:00:17 -0500 Subject: [PATCH 28/87] deps --- .../floating-focus-manager.svelte | 737 ++++++++++-------- .../floating-portal/floating-portal.svelte | 19 +- .../floating-portal/hooks.svelte.ts | 1 + .../src/internal/tabbable.ts | 1 + .../test/components/drawer/drawer.svelte | 2 +- .../floating-focus-manager.test.ts | 3 +- 6 files changed, 424 insertions(+), 339 deletions(-) diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index e011976f..13cb5c2c 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -222,7 +222,7 @@ function getTabbableElements(container?: Element) { const content = getTabbableContent(container); - return order + const ordered = order .map((type) => { if (context.domReference && type === "reference") { return context.domReference; @@ -236,54 +236,68 @@ }) .filter(Boolean) .flat() as Array; + return ordered; } - $effect(() => { - if (disabled || !modal) return; + watch( + [ + () => disabled, + () => context.domReference, + () => floatingFocusElement, + () => modal, + () => order, + () => isUntrappedTypeableCombobox, + ], + () => { + if (disabled || !modal) return; - function onKeyDown(event: KeyboardEvent) { - if (event.key !== "Tab") return; + function onKeyDown(event: KeyboardEvent) { + if (event.key !== "Tab") return; - // The focus guards have nothing to focus, so we need to stop the event. - if ( - contains( - floatingFocusElement, - activeElement(getDocument(floatingFocusElement)) - ) && - getTabbableContent().length === 0 && - !isUntrappedTypeableCombobox - ) { - stopEvent(event); - } + // The focus guards have nothing to focus, so we need to stop the event. + if ( + contains( + floatingFocusElement, + activeElement(getDocument(floatingFocusElement)) + ) && + getTabbableContent().length === 0 && + !isUntrappedTypeableCombobox + ) { + stopEvent(event); + } - const els = getTabbableElements(); - const target = getTarget(event); + const els = getTabbableElements(); + const target = getTarget(event); - if (order[0] === "reference" && target === context.domReference) { - stopEvent(event); - if (event.shiftKey) { - enqueueFocus(els[els.length - 1]); - } else { - enqueueFocus(els[1]); + if ( + order[0] === "reference" && + target === context.domReference + ) { + stopEvent(event); + if (event.shiftKey) { + enqueueFocus(els[els.length - 1]); + } else { + enqueueFocus(els[1]); + } } - } - if ( - order[1] === "floating" && - target === floatingFocusElement && - event.shiftKey - ) { - stopEvent(event); - enqueueFocus(els[0]); + if ( + order[1] === "floating" && + target === floatingFocusElement && + event.shiftKey + ) { + stopEvent(event); + enqueueFocus(els[0]); + } } - } - const doc = getDocument(floatingFocusElement); + const doc = getDocument(floatingFocusElement); - return on(doc, "keydown", onKeyDown); - }); + return on(doc, "keydown", onKeyDown); + } + ); - $effect(() => { + watch([() => disabled, () => context.floating], () => { if (disabled || !context.floating) return; function handleFocusIn(event: FocusEvent) { @@ -299,104 +313,119 @@ return on(context.floating, "focusin", handleFocusIn); }); - $effect(() => { - if (disabled || !closeOnFocusOut) return; - - // In Safari, buttons lose focus when pressing them. - function handlePointerDown() { - isPointerDown = true; - setTimeout(() => { - isPointerDown = false; - }); - } - - function handleFocusOutside(event: FocusEvent) { - const relatedTarget = event.relatedTarget as HTMLElement | null; + watch( + [ + () => disabled, + () => context.domReference, + () => context.floating, + () => floatingFocusElement, + () => modal, + () => tree, + () => portalContext, + () => closeOnFocusOut, + () => restoreFocus, + () => isUntrappedTypeableCombobox, + ], + () => { + if (disabled || !closeOnFocusOut) return; + + // In Safari, buttons lose focus when pressing them. + function handlePointerDown() { + isPointerDown = true; + setTimeout(() => { + isPointerDown = false; + }); + } - queueMicrotask(() => { - const movedToUnrelatedNode = !( - contains(context.domReference, relatedTarget) || - contains(context.floating, relatedTarget) || - contains(relatedTarget, context.floating) || - contains(portalContext?.portalNode, relatedTarget) || - relatedTarget?.hasAttribute( - createAttribute("focus-guard") - ) || - (tree && - (getChildren(tree.nodes, nodeId).find( - (node) => - contains( - node.context?.floating, - relatedTarget - ) || - contains( - node.context?.domReference, - relatedTarget - ) + function handleFocusOutside(event: FocusEvent) { + const relatedTarget = event.relatedTarget as HTMLElement | null; + + queueMicrotask(() => { + const movedToUnrelatedNode = !( + contains(context.domReference, relatedTarget) || + contains(context.floating, relatedTarget) || + contains(relatedTarget, context.floating) || + contains(portalContext?.portalNode, relatedTarget) || + relatedTarget?.hasAttribute( + createAttribute("focus-guard") ) || - getAncestors(tree.nodes, nodeId).find( + (tree && + (getChildren(tree.nodes, nodeId).find( (node) => - [ + contains( node.context?.floating, - getFloatingFocusElement( - node.context?.floating - ), - ].includes(relatedTarget) || - node.context?.domReference === relatedTarget - ))) - ); - - // Restore focus to the previous tabbable element index to prevent - // focus from being lost outside the floating tree. - if ( - restoreFocus && - movedToUnrelatedNode && - activeElement(getDocument(floatingFocusElement)) === - getDocument(floatingFocusElement).body - ) { - // Let `FloatingPortal` effect knows that focus is still inside the - // floating tree. - if (isHTMLElement(floatingFocusElement)) { - floatingFocusElement.focus(); + relatedTarget + ) || + contains( + node.context?.domReference, + relatedTarget + ) + ) || + getAncestors(tree.nodes, nodeId).find( + (node) => + [ + node.context?.floating, + getFloatingFocusElement( + node.context?.floating + ), + ].includes(relatedTarget) || + node.context?.domReference === + relatedTarget + ))) + ); + + // Restore focus to the previous tabbable element index to prevent + // focus from being lost outside the floating tree. + if ( + restoreFocus && + movedToUnrelatedNode && + activeElement(getDocument(floatingFocusElement)) === + getDocument(floatingFocusElement).body + ) { + // Let `FloatingPortal` effect knows that focus is still inside the + // floating tree. + if (isHTMLElement(floatingFocusElement)) { + floatingFocusElement.focus(); + } + + const prevTabbableIndex = tabbableIndex; + const tabbableContent = + getTabbableContent() as Array; + const nodeToFocus = + tabbableContent[prevTabbableIndex] || + tabbableContent[tabbableContent.length - 1] || + floatingFocusElement; + + if (isHTMLElement(nodeToFocus)) { + nodeToFocus.focus(); + } } - const prevTabbableIndex = tabbableIndex; - const tabbableContent = - getTabbableContent() as Array; - const nodeToFocus = - tabbableContent[prevTabbableIndex] || - tabbableContent[tabbableContent.length - 1] || - floatingFocusElement; - - if (isHTMLElement(nodeToFocus)) { - nodeToFocus.focus(); + // Focus did not move inside the floating tree, and there are no tabbable + // portal guards to handle closing. + if ( + (isUntrappedTypeableCombobox ? true : !modal) && + relatedTarget && + movedToUnrelatedNode && + !isPointerDown && + // Fix React 18 Strict Mode returnFocus due to double rendering. + relatedTarget !== getPreviouslyFocusedElement() + ) { + preventReturnFocus = true; + context.onOpenChange(false, event, "focus-out"); } - } - - // Focus did not move inside the floating tree, and there are no tabbable - // portal guards to handle closing. - if ( - (isUntrappedTypeableCombobox ? true : !modal) && - relatedTarget && - movedToUnrelatedNode && - !isPointerDown && - // Fix React 18 Strict Mode returnFocus due to double rendering. - relatedTarget !== getPreviouslyFocusedElement() - ) { - preventReturnFocus = true; - context.onOpenChange(false, event, "focus-out"); - } - }); - } + }); + } - if (context.floating && isHTMLElement(context.domReference)) { - return executeCallbacks( - on(context.domReference, "focusout", handleFocusOutside), - on(context.domReference, "pointerdown", handlePointerDown), - on(context.floating, "focusout", handleFocusOutside) - ); + if (context.floating && isHTMLElement(context.domReference)) { + return executeCallbacks( + on(context.domReference, "focusout", handleFocusOutside), + on(context.domReference, "pointerdown", handlePointerDown), + on(context.floating, "focusout", handleFocusOutside) + ); + } } - }); + ); const beforeGuardRef = box(null); const afterGuardRef = box(null); @@ -410,47 +439,61 @@ portalContext?.afterInsideRef, ]); - $effect(() => { - if (disabled || !context.floating) return; - - // Don't hide portals nested within the parent portal. - const portalNodes = Array.from( - portalContext?.portalNode?.querySelectorAll( - `[${createAttribute("portal")}]` - ) || [] - ); + watch( + [ + () => disabled, + () => context.domReference, + () => context.floating, + () => modal, + () => order, + () => portalContext, + () => isUntrappedTypeableCombobox, + () => guards, + () => useInert, + () => tree, + ], + () => { + if (disabled || !context.floating) return; + + // Don't hide portals nested within the parent portal. + const portalNodes = Array.from( + portalContext?.portalNode?.querySelectorAll( + `[${createAttribute("portal")}]` + ) || [] + ); - const ancestorFloatingNodes = - tree && !modal - ? getAncestors(tree?.nodes, nodeId).map( - (node) => node.context?.floating - ) - : []; - - const insideElements = [ - context.floating, - ...portalNodes, - ...ancestorFloatingNodes, - startDismissButtonRef.current, - endDismissButtonRef.current, - beforeGuardRef.current, - afterGuardRef.current, - portalContext?.beforeOutsideRef.current, - portalContext?.afterOutsideRef.current, - order.includes("reference") || isUntrappedTypeableCombobox - ? context.domReference - : null, - ].filter((x): x is Element => x != null); - - const cleanup = - modal || isUntrappedTypeableCombobox - ? markOthers(insideElements, !useInert, useInert) - : markOthers(insideElements); - - return () => { - cleanup(); - }; - }); + const ancestorFloatingNodes = + tree && !modal + ? getAncestors(tree?.nodes, nodeId).map( + (node) => node.context?.floating + ) + : []; + + const insideElements = [ + context.floating, + ...portalNodes, + ...ancestorFloatingNodes, + startDismissButtonRef.current, + endDismissButtonRef.current, + beforeGuardRef.current, + afterGuardRef.current, + portalContext?.beforeOutsideRef.current, + portalContext?.afterOutsideRef.current, + order.includes("reference") || isUntrappedTypeableCombobox + ? context.domReference + : null, + ].filter((x): x is Element => x != null); + + const cleanup = + modal || isUntrappedTypeableCombobox + ? markOthers(insideElements, !useInert, useInert) + : markOthers(insideElements); + + return () => { + cleanup(); + }; + } + ); watch( [ @@ -492,192 +535,233 @@ } ); - $effect(() => { - if (disabled || !floatingFocusElement) return; - - let preventReturnFocusScroll = false; - - const doc = getDocument(floatingFocusElement); - const previouslyFocusedElement = activeElement(doc); - const contextData = context.data; - let openEvent = contextData.openEvent; - - addPreviouslyFocusedElement(previouslyFocusedElement); - - // Dismissing via outside press should always ignore `returnFocus` to - // prevent unwanted scrolling. - function onOpenChange({ - open, - reason, - event, - nested, - }: { - open: boolean; - reason: OpenChangeReason; - event: Event; - nested: boolean; - }) { - if (open) { - openEvent = event; - } + watch( + [ + () => disabled, + () => context.floating, + () => floatingFocusElement, + () => returnFocus, + () => context.data, + () => context.events, + () => tree, + () => isInsidePortal, + () => context.domReference, + ], + () => { + if (disabled || !floatingFocusElement) return; - if (reason === "escape-key" && context.domReference) { - addPreviouslyFocusedElement(context.domReference); - } + let preventReturnFocusScroll = false; - if ( - ["hover", "safe-polygon"].includes(reason) && - event.type === "mouseleave" - ) { - preventReturnFocus = true; - } + const doc = getDocument(floatingFocusElement); + const previouslyFocusedElement = activeElement(doc); + const contextData = context.data; + let openEvent = contextData.openEvent; + + addPreviouslyFocusedElement(previouslyFocusedElement); + + // Dismissing via outside press should always ignore `returnFocus` to + // prevent unwanted scrolling. + function onOpenChange({ + open, + reason, + event, + nested, + }: { + open: boolean; + reason: OpenChangeReason; + event: Event; + nested: boolean; + }) { + if (open) { + openEvent = event; + } - if (reason !== "outside-press") return; + if (reason === "escape-key" && context.domReference) { + addPreviouslyFocusedElement(context.domReference); + } - if (nested) { - preventReturnFocus = false; - preventReturnFocusScroll = true; - } else { - preventReturnFocus = !( - isVirtualClick(event as MouseEvent) || - isVirtualPointerEvent(event as PointerEvent) - ); - } - } + if ( + ["hover", "safe-polygon"].includes(reason) && + event.type === "mouseleave" + ) { + preventReturnFocus = true; + } + + if (reason !== "outside-press") return; - context.events.on("openchange", onOpenChange); + if (nested) { + preventReturnFocus = false; + preventReturnFocusScroll = true; + } else { + preventReturnFocus = !( + isVirtualClick(event as MouseEvent) || + isVirtualPointerEvent(event as PointerEvent) + ); + } + } - const fallbackEl = doc.createElement("span"); - fallbackEl.setAttribute("tabindex", "-1"); - fallbackEl.setAttribute("aria-hidden", "true"); - fallbackEl.setAttribute("style", HIDDEN_STYLES_STRING); + context.events.on("openchange", onOpenChange); - if (isInsidePortal && context.domReference) { - context.domReference.insertAdjacentElement("afterend", fallbackEl); - } + const fallbackEl = doc.createElement("span"); + fallbackEl.setAttribute("tabindex", "-1"); + fallbackEl.setAttribute("aria-hidden", "true"); + fallbackEl.setAttribute("style", HIDDEN_STYLES_STRING); - function getReturnElement() { - if (typeof returnFocus === "boolean") { - return getPreviouslyFocusedElement() || fallbackEl; + if (isInsidePortal && context.domReference) { + context.domReference.insertAdjacentElement( + "afterend", + fallbackEl + ); } - return returnFocus || fallbackEl; - } + function getReturnElement() { + if (typeof returnFocus === "boolean") { + return getPreviouslyFocusedElement() || fallbackEl; + } - return () => { - context.events.off("openchange", onOpenChange); - - const isFocusInsideFloatingTree = - contains(context.floating, prevActiveElement) || - (tree && - getChildren(tree.nodes, nodeId).some((node) => - contains(node.context?.floating, prevActiveElement) - )); - const shouldFocusReference = - isFocusInsideFloatingTree || - (openEvent && ["click", "mousedown"].includes(openEvent.type)); - - if (shouldFocusReference && context.domReference) { - addPreviouslyFocusedElement(context.domReference); + return returnFocus || fallbackEl; } - const returnElement = getReturnElement(); + return () => { + context.events.off("openchange", onOpenChange); - queueMicrotask(() => { - // This is `returnElement`, if it's tabbable, or its first tabbable child. - const tabbableReturnElement = - getFirstTabbableElement(returnElement); - if ( - // eslint-disable-next-line react-hooks/exhaustive-deps - returnFocus && - !preventReturnFocus && - isHTMLElement(tabbableReturnElement) && - // If the focus moved somewhere else after mount, avoid returning focus - // since it likely entered a different element which should be - // respected: https://github.com/floating-ui/floating-ui/issues/2607 - (tabbableReturnElement !== prevActiveElement && - prevActiveElement !== doc.body - ? isFocusInsideFloatingTree - : true) - ) { - tabbableReturnElement.focus({ - preventScroll: preventReturnFocusScroll, - }); + const isFocusInsideFloatingTree = + contains(context.floating, prevActiveElement) || + (tree && + getChildren(tree.nodes, nodeId).some((node) => + contains(node.context?.floating, prevActiveElement) + )); + const shouldFocusReference = + isFocusInsideFloatingTree || + (openEvent && + ["click", "mousedown"].includes(openEvent.type)); + + if (shouldFocusReference && context.domReference) { + addPreviouslyFocusedElement(context.domReference); } - fallbackEl.remove(); - }); - }; - }); + const returnElement = getReturnElement(); + + queueMicrotask(() => { + // This is `returnElement`, if it's tabbable, or its first tabbable child. + const tabbableReturnElement = + getFirstTabbableElement(returnElement); + if ( + // eslint-disable-next-line react-hooks/exhaustive-deps + returnFocus && + !preventReturnFocus && + isHTMLElement(tabbableReturnElement) && + // If the focus moved somewhere else after mount, avoid returning focus + // since it likely entered a different element which should be + // respected: https://github.com/floating-ui/floating-ui/issues/2607 + (tabbableReturnElement !== prevActiveElement && + prevActiveElement !== doc.body + ? isFocusInsideFloatingTree + : true) + ) { + tabbableReturnElement.focus({ + preventScroll: preventReturnFocusScroll, + }); + } - $effect(() => { - disabled; - // The `returnFocus` cleanup behavior is inside a microtask; ensure we - // wait for it to complete before resetting the flag. - queueMicrotask(() => { - preventReturnFocus = false; - }); - }); + fallbackEl.remove(); + }); + }; + } + ); - $effect(() => { - if (disabled || !portalContext) return; - - portalContext.setFocusManagerState({ - modal, - open: context.open, - closeOnFocusOut, - onOpenChange: context.onOpenChange, - domReference: context.domReference, - }); - - return () => { - portalContext.setFocusManagerState(null); - }; - }); + watch( + () => disabled, + () => { + disabled; + // The `returnFocus` cleanup behavior is inside a microtask; ensure we + // wait for it to complete before resetting the flag. + queueMicrotask(() => { + preventReturnFocus = false; + }); + } + ); - $effect(() => { - if (disabled) return; - if (!floatingFocusElement) return; - if (typeof MutationObserver !== "function") return; - if (ignoreInitialFocus) return; + watch( + [ + () => disabled, + () => portalContext, + () => modal, + () => context.open, + () => closeOnFocusOut, + () => context.domReference, + ], + () => { + if (disabled || !portalContext) return; + + portalContext.setFocusManagerState({ + modal, + open: context.open, + closeOnFocusOut, + onOpenChange: context.onOpenChange, + domReference: context.domReference, + }); - const handleMutation = () => { - const tabIndex = floatingFocusElement.getAttribute("tabindex"); - const tabbableContent = - getTabbableContent() as Array; - const activeEl = activeElement(getDocument(context.floating)); - const _tabbableIndex = tabbableContent.indexOf(activeEl); + return () => { + portalContext.setFocusManagerState(null); + }; + } + ); - if (_tabbableIndex !== -1) { - tabbableIndex = _tabbableIndex; - } + watch( + [ + () => disabled, + () => context.floating, + () => floatingFocusElement, + () => context.domReference, + () => order, + () => ignoreInitialFocus, + ], + () => { + if (disabled) return; + if (!floatingFocusElement) return; + if (typeof MutationObserver !== "function") return; + if (ignoreInitialFocus) return; + tabbableIndex; + + const handleMutation = () => { + const tabIndex = floatingFocusElement.getAttribute("tabindex"); + const tabbableContent = + getTabbableContent() as Array; + const activeEl = activeElement(getDocument(context.floating)); + const _tabbableIndex = tabbableContent.indexOf(activeEl); + + if (_tabbableIndex !== -1) { + tabbableIndex = _tabbableIndex; + } - if ( - order.includes("floating") || - (activeEl !== context.domReference && - tabbableContent.length === 0) - ) { - if (tabIndex !== "0") { - floatingFocusElement.setAttribute("tabindex", "0"); + if ( + order.includes("floating") || + (activeEl !== context.domReference && + tabbableContent.length === 0) + ) { + if (tabIndex !== "0") { + floatingFocusElement.setAttribute("tabindex", "0"); + } + } else if (tabIndex !== "-1") { + floatingFocusElement.setAttribute("tabindex", "-1"); } - } else if (tabIndex !== "-1") { - floatingFocusElement.setAttribute("tabindex", "-1"); - } - }; + }; - handleMutation(); - const observer = new MutationObserver(handleMutation); + handleMutation(); + const observer = new MutationObserver(handleMutation); - observer.observe(floatingFocusElement, { - childList: true, - subtree: true, - attributes: true, - }); + observer.observe(floatingFocusElement, { + childList: true, + subtree: true, + attributes: true, + }); - return () => { - observer.disconnect(); - }; - }); + return () => { + observer.disconnect(); + }; + } + ); const shouldRenderGuards = $derived( !disabled && @@ -717,7 +801,6 @@ if (isOutsideEvent(event, portalContext.portalNode)) { const nextTabbable = getNextTabbable() || context.domReference; - nextTabbable?.focus(); } else { portalContext.beforeOutsideRef.current?.focus(); diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index 78864a10..61b4ce7b 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -77,33 +77,32 @@ watch( [() => portalNode.current, () => preserveTabOrder, () => modal], - ([portalNode, preserveTabOrder, modal]) => { - if (!portalNode || !preserveTabOrder || modal) return; + () => { + if (!portalNode.current || !preserveTabOrder || modal) return; // Make sure elements inside the portal element are tabbable only when the // portal has already been focused, either by tabbing into a focus trap // element outside or using the mouse. function onFocus(event: FocusEvent) { - if (portalNode && isOutsideEvent(event)) { + if (portalNode.current && isOutsideEvent(event)) { const focusing = event.type === "focusin"; const manageFocus = focusing ? enableFocusInside : disableFocusInside; - manageFocus(portalNode); + manageFocus(portalNode.current); } } return executeCallbacks( - on(portalNode, "focusin", onFocus, { capture: true }), - on(portalNode, "focusout", onFocus, { capture: true }) + on(portalNode.current, "focusin", onFocus, { capture: true }), + on(portalNode.current, "focusout", onFocus, { capture: true }) ); } ); - watch([() => portalNode.current, () => open], ([portalNode, open]) => { - if (!portalNode || open) return; - - enableFocusInside(portalNode); + watch([() => portalNode.current, () => open], () => { + if (!portalNode.current || open) return; + enableFocusInside(portalNode.current); }); PortalContext.set({ diff --git a/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts b/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts index 36ab586a..5f81e4ce 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts +++ b/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts @@ -57,6 +57,7 @@ function useFloatingPortalNode(props: UseFloatingPortalNodeProps = {}) { (id) => { if (portalNode) return; const existingIdRoot = id ? document.getElementById(id) : null; + console.log(existingIdRoot); if (!existingIdRoot) return; const subRoot = document.createElement("div"); diff --git a/packages/floating-ui-svelte/src/internal/tabbable.ts b/packages/floating-ui-svelte/src/internal/tabbable.ts index d3265bca..eb48febe 100644 --- a/packages/floating-ui-svelte/src/internal/tabbable.ts +++ b/packages/floating-ui-svelte/src/internal/tabbable.ts @@ -56,6 +56,7 @@ function enableFocusInside(container: HTMLElement) { for (const element of elements) { const tabindex = element.dataset.tabindex; delete element.dataset.tabindex; + element.removeAttribute("data-tabindex"); if (tabindex) { element.setAttribute("tabindex", tabindex); } else { diff --git a/packages/floating-ui-svelte/test/components/drawer/drawer.svelte b/packages/floating-ui-svelte/test/components/drawer/drawer.svelte index 98eb5d7b..3b92ac4f 100644 --- a/packages/floating-ui-svelte/test/components/drawer/drawer.svelte +++ b/packages/floating-ui-svelte/test/components/drawer/drawer.svelte @@ -80,7 +80,7 @@ {@render reference?.(ref, ints.getReferenceProps())} {#if open} - + {#if modal} { expect(screen.queryByText("Close")).toBeInTheDocument(); }); - it.only("returns focus when tabbing out then back to close button", async () => { + // TODO: fix this test... focus is going to the floating content instead of the close button + it("returns focus when tabbing out then back to close button", async () => { render(Drawer); await userEvent.click(screen.getByText("My button")); From 2e0da086b66b4accd0a7ed5cd56d103ae8090d63 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 15 Jan 2025 19:24:44 -0500 Subject: [PATCH 29/87] housekeeping --- packages/floating-ui-svelte/src/app.d.ts | 9 ++- .../floating-focus-manager.svelte | 61 +++++++++++-------- .../src/components/floating-overlay.svelte | 16 ++--- .../floating-portal/floating-portal.svelte | 15 +++-- .../floating-portal/hooks.svelte.ts | 7 +-- .../src/components/focus-guard.svelte | 2 +- .../routes/(inner)/sink/(drawer)/main.svelte | 12 +++- 7 files changed, 73 insertions(+), 49 deletions(-) diff --git a/packages/floating-ui-svelte/src/app.d.ts b/packages/floating-ui-svelte/src/app.d.ts index 89f0ac89..cdbaaf52 100644 --- a/packages/floating-ui-svelte/src/app.d.ts +++ b/packages/floating-ui-svelte/src/app.d.ts @@ -1,7 +1,12 @@ +// These global variables enable projects that potentially use multiple versions of +// floating ui via dependencies, etc. to share these variables across all instances. +// From experience with vaul-svelte using Bits UI under the hood, without these globals, +// composing multiple instances of floating ui components would not work as expected. declare global { - var fui_lock_count: { current: number }; + var fuiLockCount: { current: number }; // biome-ignore lint/complexity/noBannedTypes: - var fui_lock_cleanup: { current: Function }; + var fuiLockCleanup: { current: Function }; + var fuiPrevFocusedElements: Element[]; } export {}; diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index 13cb5c2c..194a8f92 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -31,29 +31,27 @@ import { getAncestors } from "../../internal/get-ancestors.js"; import { executeCallbacks } from "../../internal/execute-callbacks.js"; import type { Boxed, OpenChangeReason } from "../../types.js"; - import { useMergeRefs } from "../../hooks/use-merge-refs.svelte.js"; import { watch } from "../../internal/watch.svelte.js"; import FocusGuard, { HIDDEN_STYLES_STRING } from "../focus-guard.svelte"; const LIST_LIMIT = 20; - let previouslyFocusedElements: Element[] = []; + globalThis.fuiPrevFocusedElements ??= []; function addPreviouslyFocusedElement(element: Element | null) { - previouslyFocusedElements = previouslyFocusedElements.filter( - (el) => el.isConnected - ); + globalThis.fuiPrevFocusedElements = + globalThis.fuiPrevFocusedElements.filter((el) => el.isConnected); if (element && getNodeName(element) !== "body") { - previouslyFocusedElements.push(element); - if (previouslyFocusedElements.length > LIST_LIMIT) { - previouslyFocusedElements = - previouslyFocusedElements.slice(-LIST_LIMIT); + globalThis.fuiPrevFocusedElements.push(element); + if (globalThis.fuiPrevFocusedElements.length > LIST_LIMIT) { + globalThis.fuiPrevFocusedElements = + globalThis.fuiPrevFocusedElements.slice(-LIST_LIMIT); } } } function getPreviouslyFocusedElement() { - return previouslyFocusedElements + return globalThis.fuiPrevFocusedElements .slice() .reverse() .find((el) => el.isConnected); @@ -198,8 +196,8 @@ const startDismissButtonRef = box(null!); const endDismissButtonRef = box(null!); - let preventReturnFocus = $state(false); - let isPointerDown = $state(false); + let preventReturnFocus = false; + let isPointerDown = false; let tabbableIndex = $state(-1); let prevActiveElement: Element | null = null; @@ -338,6 +336,7 @@ } function handleFocusOutside(event: FocusEvent) { + console.log("focus outside"); const relatedTarget = event.relatedTarget as HTMLElement | null; queueMicrotask(() => { @@ -430,14 +429,20 @@ const beforeGuardRef = box(null); const afterGuardRef = box(null); - const mergedBeforeGuardRef = useMergeRefs([ - beforeGuardRef, - portalContext?.beforeInsideRef, - ]); - const mergedAfterGuardRef = useMergeRefs([ - afterGuardRef, - portalContext?.afterInsideRef, - ]); + watch.pre( + () => beforeGuardRef.current, + () => { + if (!portalContext) return; + portalContext.beforeInsideRef.current = beforeGuardRef.current; + } + ); + watch.pre( + () => afterGuardRef.current, + () => { + if (!portalContext) return; + portalContext.afterInsideRef.current = afterGuardRef.current; + } + ); watch( [ @@ -495,7 +500,7 @@ } ); - watch( + watch.pre( [ () => disabled, () => context.open, @@ -535,7 +540,7 @@ } ); - watch( + watch.pre( [ () => disabled, () => context.floating, @@ -682,7 +687,7 @@ } ); - watch( + watch.pre( [ () => disabled, () => portalContext, @@ -708,7 +713,7 @@ } ); - watch( + watch.pre( [ () => disabled, () => context.floating, @@ -722,7 +727,6 @@ if (!floatingFocusElement) return; if (typeof MutationObserver !== "function") return; if (ignoreInitialFocus) return; - tabbableIndex; const handleMutation = () => { const tabIndex = floatingFocusElement.getAttribute("tabindex"); @@ -786,8 +790,10 @@ {#if shouldRenderGuards} beforeGuardRef.current, + (v) => (beforeGuardRef.current = v)} onfocus={(event) => { + console.log("inside-before"); if (modal) { const els = getTabbableElements(); enqueueFocus( @@ -820,7 +826,8 @@ will have a dismiss button. {#if shouldRenderGuards} afterGuardRef.current, + (v) => (afterGuardRef.current = v)} onfocus={(event) => { if (modal) { enqueueFocus(getTabbableElements()[0]); diff --git a/packages/floating-ui-svelte/src/components/floating-overlay.svelte b/packages/floating-ui-svelte/src/components/floating-overlay.svelte index 8cdf2991..1863baca 100644 --- a/packages/floating-ui-svelte/src/components/floating-overlay.svelte +++ b/packages/floating-ui-svelte/src/components/floating-overlay.svelte @@ -5,8 +5,8 @@ // So we can use the same object even if multiple libs with different versions // that use floating UI are loaded :) - globalThis.fui_lock_count ??= { current: 0 }; - globalThis.fui_lock_cleanup ??= { current: () => {} }; + globalThis.fuiLockCount ??= { current: 0 }; + globalThis.fuiLockCleanup ??= { current: () => {} }; interface FloatingOverlayProps extends Partial, @@ -94,15 +94,15 @@ $effect(() => { if (!lockScroll) return; - globalThis.fui_lock_count.current++; - if (globalThis.fui_lock_count.current === 1) { - globalThis.fui_lock_cleanup.current = enableScrollLock(); + globalThis.fuiLockCount.current++; + if (globalThis.fuiLockCount.current === 1) { + globalThis.fuiLockCleanup.current = enableScrollLock(); } return () => { - globalThis.fui_lock_count.current--; - if (globalThis.fui_lock_count.current === 0) { - globalThis.fui_lock_cleanup.current(); + globalThis.fuiLockCount.current--; + if (globalThis.fuiLockCount.current === 0) { + globalThis.fuiLockCleanup.current(); } }; }); diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index 61b4ce7b..a88e1b49 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -1,5 +1,5 @@ + + +
    + +
    +
    diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected.svelte new file mode 100644 index 00000000..4323aa47 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/connected.svelte @@ -0,0 +1,45 @@ + + + +{#if open} + +
    + Parent Floating + +
    +
    +{/if} +{#if isDrawerOpen} + (isDrawerOpen = v)} /> +{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte new file mode 100644 index 00000000..e4c9a518 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte @@ -0,0 +1,35 @@ + + + + +{#if open} + +
    +
    +
    +
    +{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/restore-focus.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/restore-focus.svelte new file mode 100644 index 00000000..277b59f9 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/restore-focus.svelte @@ -0,0 +1,51 @@ + + + +{#if open} + +
    + {#if removedIndex < 3} + + {/if} + {#if removedIndex < 1} + + {/if} + {#if removedIndex < 2} + + {/if} +
    +
    +{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/trapped-combobox.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/trapped-combobox.svelte new file mode 100644 index 00000000..7b9b69fc --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/trapped-combobox.svelte @@ -0,0 +1,45 @@ + + +
    + + + {#if open} + +
    + + +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/untrapped-combobox.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/untrapped-combobox.svelte new file mode 100644 index 00000000..b8fc064b --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/untrapped-combobox.svelte @@ -0,0 +1,52 @@ + + +
    + + + {#if open} + + +
    + + +
    +
    +
    + {/if} + +
    diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index 18e67885..2b9fa927 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -15,6 +15,11 @@ import KeepMounted from "./components/keep-mounted.svelte"; import NonModalFloatingPortal from "./components/non-modal-floating-portal.svelte"; import Navigation from "../navigation/main.svelte"; import Drawer from "../drawer/main.svelte"; +import RestoreFocus from "./components/restore-focus.svelte"; +import TrappedCombobox from "./components/trapped-combobox.svelte"; +import UntrappedCombobox from "./components/untrapped-combobox.svelte"; +import Connected from "./components/connected.svelte"; +import FloatingWrapper from "./components/floating-wrapper.svelte"; describe("initialFocus", () => { it("handles numbers", async () => { @@ -653,3 +658,98 @@ describe("Drawer", () => { await waitFor(() => expect(screen.getByText("My button")).toHaveFocus()); }); }); + +describe("restoreFocus", () => { + it("true: restores focus to nearest tabbable element if currently focused element is removed", async () => { + render(RestoreFocus); + + await userEvent.click(screen.getByTestId("reference")); + + const one = screen.getByRole("button", { name: "one" }); + const two = screen.getByRole("button", { name: "two" }); + const three = screen.getByRole("button", { name: "three" }); + const floating = screen.getByTestId("floating"); + + await waitFor(() => expect(one).toHaveFocus()); + await fireEvent.click(one); + await fireEvent.focusOut(floating); + + expect(two).toHaveFocus(); + await fireEvent.click(two); + await fireEvent.focusOut(floating); + + expect(three).toHaveFocus(); + await fireEvent.click(three); + await fireEvent.focusOut(floating); + + expect(floating).toHaveFocus(); + }); + + it("false: does not restore focus to nearest tabbable element if currently focused element is removed", async () => { + render(RestoreFocus, { restoreFocus: false }); + + await userEvent.click(screen.getByTestId("reference")); + + const one = screen.getByRole("button", { name: "one" }); + const floating = screen.getByTestId("floating"); + + await waitFor(() => expect(one).toHaveFocus()); + await fireEvent.click(one); + await fireEvent.focusOut(floating); + + expect(document.body).toHaveFocus(); + }); +}); + +it("trapped combobox prevents focus moving outside floating element", async () => { + render(TrappedCombobox); + await userEvent.click(screen.getByTestId("input")); + await waitFor(() => expect(screen.getByTestId("input")).not.toHaveFocus()); + await waitFor(() => + expect(screen.getByRole("button", { name: "one" })).toHaveFocus(), + ); + await userEvent.tab(); + await waitFor(() => + expect(screen.getByRole("button", { name: "two" })).toHaveFocus(), + ); + await userEvent.tab(); + await waitFor(() => + expect(screen.getByRole("button", { name: "one" })).toHaveFocus(), + ); +}); + +it("untrapped combobox creates non-modal focus management", async () => { + render(UntrappedCombobox); + await userEvent.click(screen.getByTestId("input")); + await waitFor(() => expect(screen.getByTestId("input")).toHaveFocus()); + await userEvent.tab(); + await waitFor(() => + expect(screen.getByRole("button", { name: "one" })).toHaveFocus(), + ); + await userEvent.tab({ shift: true }); + await waitFor(() => expect(screen.getByTestId("input")).toHaveFocus()); +}); + +it("returns focus to the last connected element", async () => { + render(Connected); + await userEvent.click(screen.getByTestId("parent-reference")); + await waitFor(() => + expect(screen.getByTestId("parent-floating-reference")).toHaveFocus(), + ); + await userEvent.click(screen.getByTestId("parent-floating-reference")); + await waitFor(() => + expect(screen.getByTestId("child-reference")).toHaveFocus(), + ); + await userEvent.keyboard(testKbd.ESCAPE); + await waitFor(() => + expect(screen.getByTestId("parent-reference")).toHaveFocus(), + ); +}); + +it.only("places focus on an element with floating props when floating element is a wrapper", async () => { + render(FloatingWrapper); + + await userEvent.click(screen.getByRole("button")); + + await waitFor(() => expect(screen.getByTestId("inner")).toHaveFocus()); +}); diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte index 27b3a2b2..2bb74c03 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -1,52 +1,35 @@ -
    - - From 13efcdce2aa73c5c4b0c759f669e0acfdd0bb7ac Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 18 Jan 2025 21:39:37 -0500 Subject: [PATCH 35/87] more --- .../floating-focus-manager/floating-focus-manager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index 2b9fa927..1e11fdc1 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -746,7 +746,7 @@ it("returns focus to the last connected element", async () => { ); }); -it.only("places focus on an element with floating props when floating element is a wrapper", async () => { +it("places focus on an element with floating props when floating element is a wrapper", async () => { render(FloatingWrapper); await userEvent.click(screen.getByRole("button")); From 6bf78523bce603f166a9476633817469bf374229 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 18 Jan 2025 21:48:01 -0500 Subject: [PATCH 36/87] wow that was a lot of tests --- .../floating-focus-manager.svelte | 1 - .../internal/get-floating-focus-element.ts | 6 +-- .../src/internal/tabbable.ts | 2 - .../components/hover.svelte | 34 +++++++++++++++ .../components/modal-combobox.svelte | 38 +++++++++++++++++ .../floating-focus-manager.test.ts | 42 +++++++++++++++++++ 6 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/hover.svelte create mode 100644 packages/floating-ui-svelte/test/components/floating-focus-manager/components/modal-combobox.svelte diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index a3d9503f..061ab184 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -850,7 +850,6 @@ will have a dismiss button. prevTabbable?.focus(); }); } else { - console.log("inside-after - not outside event"); portalContext.afterOutsideRef?.focus(); } } diff --git a/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts index 9ebfbfa1..21442e62 100644 --- a/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts +++ b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts @@ -8,10 +8,8 @@ export function getFloatingFocusElement( // This indicates the floating element is acting as a positioning wrapper, and // so focus should be managed on the child element with the event handlers and // aria props. - const res = floatingElement.hasAttribute(FOCUSABLE_ATTRIBUTE) + return floatingElement.hasAttribute(FOCUSABLE_ATTRIBUTE) ? floatingElement : floatingElement.querySelector(`[${FOCUSABLE_ATTRIBUTE}]`) || - floatingElement; - console.log(res); - return res as HTMLElement; + floatingElement; } diff --git a/packages/floating-ui-svelte/src/internal/tabbable.ts b/packages/floating-ui-svelte/src/internal/tabbable.ts index bc2fd9cf..5e26a453 100644 --- a/packages/floating-ui-svelte/src/internal/tabbable.ts +++ b/packages/floating-ui-svelte/src/internal/tabbable.ts @@ -23,8 +23,6 @@ function getTabbableIn(container: HTMLElement, direction: "next" | "prev") { } const activeEl = activeElement(getDocument(container)) as HTMLElement; - console.log(allTabbable); - console.log(activeEl); const activeIndex = allTabbable.indexOf(activeEl); const nextTabbableElements = allTabbable.slice(activeIndex + 1); diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/hover.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/hover.svelte new file mode 100644 index 00000000..40052ba4 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/hover.svelte @@ -0,0 +1,34 @@ + + + +{#if open} + +
    +
    +
    +{/if} + diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/modal-combobox.svelte b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/modal-combobox.svelte new file mode 100644 index 00000000..84a4a940 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/components/modal-combobox.svelte @@ -0,0 +1,38 @@ + + + + +{#if open} + +
    + +
    +
    +{/if} + + + +
    + Reference +
    +{#if open} +
    + Floating +
    +{/if} +{rect?.x} +{rect?.y} +{rect?.width} +{rect?.height} + diff --git a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte index 2bb74c03..f259cb10 100644 --- a/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -1,11 +1,22 @@ - - + {...ints.getReferenceProps()}> + Reference +
    {#if open} - -
    -
    -
    -
    +
    + Floating +
    {/if} + +{rect?.x} +{rect?.y} +{rect?.width} +{rect?.height} + From eda47fdb5b4b47b50da596fa8fdf73606af86805 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 14:28:34 -0500 Subject: [PATCH 38/87] feat: useClientPoint --- .../src/hooks/use-client-point.svelte.ts | 58 ++++++++--- .../test/hooks/use-client-point.test.ts | 98 ++++++++++++++++++- 2 files changed, 141 insertions(+), 15 deletions(-) diff --git a/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts index 82c82f36..bcd450d0 100644 --- a/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts @@ -9,7 +9,6 @@ import type { ContextData, MaybeGetter } from "../types.js"; import type { FloatingContext } from "./use-floating.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; import { watch } from "../internal/watch.svelte.js"; -import { on } from "svelte/events"; function createVirtualElement( domElement: Element | null | undefined, @@ -122,8 +121,14 @@ class ClientPointInteraction implements ElementProps { #y = $derived.by(() => extract(this.options?.y, null)); #initial = false; #cleanupListener: (() => void) | null = null; + #pointerType = $state(); - #addListenerDeps = $state(0); + #listenerDeps = $state.raw([]); + + // If the pointer is a mouse-like pointer, we want to continue following the + // mouse even if the floating element is transitioning out. On touch + // devices, this is undesirable because the floating element will move to + // the dismissal touch point. #openCheck = $derived.by(() => isMouseLikePointerType(this.#pointerType) ? this.context.floating @@ -135,7 +140,7 @@ class ClientPointInteraction implements ElementProps { private readonly options: UseClientPointOptions = {}, ) { watch( - () => this.#addListenerDeps, + () => this.#listenerDeps, () => { return this.#addListener(); }, @@ -153,12 +158,27 @@ class ClientPointInteraction implements ElementProps { } }); - watch([() => this.#enabled, () => this.#x, () => this.#y], () => { + watch.pre([() => this.#enabled, () => this.#x, () => this.#y], () => { if (this.#enabled && (this.#x != null || this.#y != null)) { this.#initial = false; this.#setReference(this.#x, this.#y); } }); + + watch([() => this.#enabled, () => this.context.open], () => { + if (this.#enabled && this.context.open) { + this.#listenerDeps = []; + } + }); + + watch( + () => this.#openCheck, + () => { + if (!this.#openCheck && this.#cleanupListener) { + this.#cleanupListener(); + } + }, + ); } #setReference = (x: number | null, y: number | null) => { @@ -194,29 +214,39 @@ class ClientPointInteraction implements ElementProps { // If there's no cleanup, there's no listener, but we want to ensure // we add the listener if the cursor landed on the floating element and // then back on the reference (i.e. it's interactive). - this.#addListenerDeps++; + this.#listenerDeps = []; } }; - #addListener = () => { - // Explicitly specified `x`/`y` coordinates shouldn't add a listener. + #addListener() { if ( !this.#openCheck || !this.#enabled || this.#x != null || this.#y != null - ) - return () => {}; + ) { + // Clear existing listener when conditions change + if (this.#cleanupListener) { + this.#cleanupListener(); + this.#cleanupListener = null; + } + return; + } + + // Clear existing listener before adding new one + if (this.#cleanupListener) { + this.#cleanupListener(); + this.#cleanupListener = null; + } const win = getWindow(this.context.floating); const handleMouseMove = (event: MouseEvent) => { const target = getTarget(event) as Element | null; - if (!contains(this.context.floating, target)) { this.#setReference(event.clientX, event.clientY); } else { - this.#cleanupListener?.(); + win.removeEventListener("mousemove", handleMouseMove); this.#cleanupListener = null; } }; @@ -225,9 +255,9 @@ class ClientPointInteraction implements ElementProps { !this.context.data.openEvent || isMouseBasedEvent(this.context.data.openEvent) ) { - const removeListener = on(win, "mousemove", handleMouseMove); + win.addEventListener("mousemove", handleMouseMove); const cleanup = () => { - removeListener(); + win.removeEventListener("mousemove", handleMouseMove); this.#cleanupListener = null; }; this.#cleanupListener = cleanup; @@ -235,7 +265,7 @@ class ClientPointInteraction implements ElementProps { } this.context.setPositionReference(this.context.domReference); - }; + } #setPointerType = (event: PointerEvent) => { this.#pointerType = event.pointerType; diff --git a/packages/floating-ui-svelte/test/hooks/use-client-point.test.ts b/packages/floating-ui-svelte/test/hooks/use-client-point.test.ts index fec8f083..65b314e3 100644 --- a/packages/floating-ui-svelte/test/hooks/use-client-point.test.ts +++ b/packages/floating-ui-svelte/test/hooks/use-client-point.test.ts @@ -29,7 +29,11 @@ it("renders at mouse event coords", async () => { await fireEvent( screen.getByTestId("reference"), - new MouseEvent("mousemove", { clientX: 500, clientY: 500, bubbles: true }), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 500, + clientY: 500, + }), ); expectLocation({ x: 500, y: 500 }); @@ -56,6 +60,30 @@ it("renders at mouse event coords", async () => { ); expectLocation({ x: 1000, y: 1000 }); + + await fireEvent.click(screen.getByTestId("toggle-open")); + + await fireEvent( + screen.getByTestId("reference"), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 700, + clientY: 700, + }), + ); + + expectLocation({ x: 700, y: 700 }); + + await fireEvent( + document.body, + new MouseEvent("mousemove", { + bubbles: true, + clientX: 0, + clientY: 0, + }), + ); + + expectLocation({ x: 0, y: 0 }); }); it("ignores mouse when explicit coords are specified", async () => { @@ -126,3 +154,71 @@ it("cleans up window listener when closing or disabling", async () => { expectLocation({ x: 500, y: 500 }); }); + +it("respects axis x", async () => { + render(UseClientPoint, { axis: "x" }); + + await fireEvent.click(screen.getByTestId("toggle-open")); + + await fireEvent( + screen.getByTestId("reference"), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 500, + clientY: 500, + }), + ); + + expectLocation({ x: 500, y: 0 }); +}); + +it("respects axis y", async () => { + render(UseClientPoint, { axis: "y" }); + + await fireEvent.click(screen.getByTestId("toggle-open")); + + await fireEvent( + screen.getByTestId("reference"), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 500, + clientY: 500, + }), + ); + + expectLocation({ x: 0, y: 500 }); +}); + +it("removes window listener when cursor lands on floating element", async () => { + render(UseClientPoint); + await fireEvent.click(screen.getByTestId("toggle-open")); + + await fireEvent( + screen.getByTestId("reference"), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 500, + clientY: 500, + }), + ); + + await fireEvent( + screen.getByTestId("floating"), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 500, + clientY: 500, + }), + ); + + await fireEvent( + document.body, + new MouseEvent("mousemove", { + bubbles: true, + clientX: 0, + clientY: 0, + }), + ); + + expectLocation({ x: 500, y: 500 }); +}); From cfbfb58b344e4cd983a35de6002de36bd0b8fedd Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 15:49:43 -0500 Subject: [PATCH 39/87] feat: floating delay group --- .../src/components/floating-arrow.svelte | 4 +- .../components/floating-delay-group.svelte | 212 ++++++++++++++++++ .../floating-focus-manager.svelte | 2 +- .../floating-portal/floating-portal.svelte | 2 +- .../components/floating-tree/hooks.svelte.ts | 8 +- .../src/hooks/use-hover.svelte.ts | 2 +- packages/floating-ui-svelte/src/index.ts | 3 + .../src/internal/on-mount-effect.svelte.ts | 19 ++ .../floating-ui-svelte/src/internal/sleep.ts | 5 + 9 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 packages/floating-ui-svelte/src/components/floating-delay-group.svelte create mode 100644 packages/floating-ui-svelte/src/internal/on-mount-effect.svelte.ts create mode 100644 packages/floating-ui-svelte/src/internal/sleep.ts diff --git a/packages/floating-ui-svelte/src/components/floating-arrow.svelte b/packages/floating-ui-svelte/src/components/floating-arrow.svelte index 335de3e9..54540209 100644 --- a/packages/floating-ui-svelte/src/components/floating-arrow.svelte +++ b/packages/floating-ui-svelte/src/components/floating-arrow.svelte @@ -2,7 +2,7 @@ import type { SVGAttributes } from "svelte/elements"; import type { FloatingContext } from "../hooks/use-floating.svelte.js"; - export interface FloatingArrowProps extends SVGAttributes { + interface FloatingArrowProps extends SVGAttributes { /** The bound HTML element reference. */ ref: Element | null; /** The floating context. */ @@ -43,6 +43,8 @@ */ strokeWidth?: number; } + + export type { FloatingArrowProps }; + + + +{@render children?.()} diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index 061ab184..8740587a 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -843,7 +843,7 @@ will have a dismiss button. } if (isOutsideEvent(event, portalContext.portalNode)) { - sleep(0).then(() => { + sleep().then(() => { const prevTabbable = getPreviousTabbable() || context.domReference; diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index 452d702c..436759cf 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -41,7 +41,7 @@ + + + + {#snippet reference(ref, props)} + + {/snippet} + + + {#snippet reference(ref, props)} + + {/snippet} + + + {#snippet reference(ref, props)} + + {/snippet} + + diff --git a/packages/floating-ui-svelte/test/components/floating-delay-group/components/tooltip.svelte b/packages/floating-ui-svelte/test/components/floating-delay-group/components/tooltip.svelte new file mode 100644 index 00000000..774b1fd8 --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-delay-group/components/tooltip.svelte @@ -0,0 +1,60 @@ + + +{@render reference(ref, ints.getReferenceProps())} + +{#if open} +
    + {label} +
    +{/if} diff --git a/packages/floating-ui-svelte/test/components/floating-delay-group/floating-delay-group.test.ts b/packages/floating-ui-svelte/test/components/floating-delay-group/floating-delay-group.test.ts new file mode 100644 index 00000000..531e640c --- /dev/null +++ b/packages/floating-ui-svelte/test/components/floating-delay-group/floating-delay-group.test.ts @@ -0,0 +1,104 @@ +import { act, fireEvent, render, screen } from "@testing-library/svelte"; +import { expect, it, vi } from "vitest"; +import Main from "./components/main.svelte"; + +vi.useFakeTimers(); + +it("groups delays correctly", async () => { + render(Main); + + await fireEvent.mouseEnter(screen.getByTestId("reference-one")); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(screen.queryByTestId("floating-one")).not.toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(999); + }); + + expect(screen.queryByTestId("floating-one")).toBeInTheDocument(); + await fireEvent.mouseEnter(screen.getByTestId("reference-two")); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(screen.queryByTestId("floating-one")).not.toBeInTheDocument(); + expect(screen.queryByTestId("floating-two")).toBeInTheDocument(); + + await fireEvent.mouseEnter(screen.getByTestId("reference-three")); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(screen.queryByTestId("floating-two")).not.toBeInTheDocument(); + expect(screen.queryByTestId("floating-three")).toBeInTheDocument(); + + await fireEvent.mouseLeave(screen.getByTestId("reference-three")); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(screen.queryByTestId("floating-three")).toBeInTheDocument(); + await act(async () => { + vi.advanceTimersByTime(199); + }); + + expect(screen.queryByTestId("floating-three")).not.toBeInTheDocument(); +}); + +it("respects timeoutMs prop", async () => { + render(Main, { timeoutMs: 500 }); + + await fireEvent.mouseEnter(screen.getByTestId("reference-one")); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + await fireEvent.mouseLeave(screen.getByTestId("reference-one")); + + expect(screen.queryByTestId("floating-one")).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(499); + }); + + expect(screen.queryByTestId("floating-one")).not.toBeInTheDocument(); + + await fireEvent.mouseEnter(screen.getByTestId("reference-two")); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(screen.queryByTestId("floating-two")).toBeInTheDocument(); + + await fireEvent.mouseEnter(screen.getByTestId("reference-three")); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(screen.queryByTestId("floating-two")).not.toBeInTheDocument(); + expect(screen.queryByTestId("floating-three")).toBeInTheDocument(); + + await fireEvent.mouseLeave(screen.getByTestId("reference-three")); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(screen.queryByTestId("floating-three")).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(99); + }); + + expect(screen.queryByTestId("floating-three")).not.toBeInTheDocument(); +}); diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index 7e183afb..5141976f 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -1,9 +1,9 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; -import { describe, expect, it, test, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { userEvent } from "@testing-library/user-event"; import Main from "./components/main.svelte"; import NestedNested from "./components/nested-nested.svelte"; import DialogNonFocusableRef from "./components/dialog-non-focusable-ref.svelte"; -import { userEvent } from "@testing-library/user-event"; import { sleep, testKbd } from "../../internal/utils.js"; import DialogFallbackRef from "./components/dialog-fallback-ref.svelte"; import Combobox from "./components/combobox.svelte"; From f2c5069822f9c5745ac448efa438ae3dc4fde6fe Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 16:14:57 -0500 Subject: [PATCH 41/87] more --- .../test/internal/get-ancestors.test.ts | 18 +++ .../test/internal/get-children.test.ts | 25 ++++ .../test/internal/mark-others.test.ts | 114 ++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 packages/floating-ui-svelte/test/internal/get-ancestors.test.ts create mode 100644 packages/floating-ui-svelte/test/internal/get-children.test.ts create mode 100644 packages/floating-ui-svelte/test/internal/mark-others.test.ts diff --git a/packages/floating-ui-svelte/test/internal/get-ancestors.test.ts b/packages/floating-ui-svelte/test/internal/get-ancestors.test.ts new file mode 100644 index 00000000..a6473769 --- /dev/null +++ b/packages/floating-ui-svelte/test/internal/get-ancestors.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from "vitest"; +import { getAncestors } from "../../src/internal/get-ancestors.js"; + +it("returns an array of ancestors", () => { + expect( + getAncestors( + [ + { id: "0", parentId: null }, + { id: "1", parentId: "0" }, + { id: "2", parentId: "1" }, + ], + "2", + ), + ).toEqual([ + { id: "1", parentId: "0" }, + { id: "0", parentId: null }, + ]); +}); diff --git a/packages/floating-ui-svelte/test/internal/get-children.test.ts b/packages/floating-ui-svelte/test/internal/get-children.test.ts new file mode 100644 index 00000000..ce5961cf --- /dev/null +++ b/packages/floating-ui-svelte/test/internal/get-children.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import type { FloatingContext } from "../../src/index.js"; +import { getChildren } from "../../src/internal/get-children.js"; + +const contextOpen = { open: true } as FloatingContext; +const contextClosed = { open: false } as FloatingContext; + +it("returns an array of children, ignoring closed ones", () => { + expect( + getChildren( + [ + { id: "0", parentId: null, context: contextOpen }, + { id: "1", parentId: "0", context: contextOpen }, + { id: "2", parentId: "1", context: contextOpen }, + { id: "3", parentId: "1", context: contextOpen }, + { id: "4", parentId: "1", context: contextClosed }, + ], + "0", + ), + ).toEqual([ + { id: "1", parentId: "0", context: contextOpen }, + { id: "2", parentId: "1", context: contextOpen }, + { id: "3", parentId: "1", context: contextOpen }, + ]); +}); diff --git a/packages/floating-ui-svelte/test/internal/mark-others.test.ts b/packages/floating-ui-svelte/test/internal/mark-others.test.ts new file mode 100644 index 00000000..01dc9bf9 --- /dev/null +++ b/packages/floating-ui-svelte/test/internal/mark-others.test.ts @@ -0,0 +1,114 @@ +import { afterEach, expect, it } from "vitest"; +import { markOthers } from "../../src/internal/mark-others.js"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +it("single call", () => { + const other = document.createElement("div"); + document.body.appendChild(other); + const target = document.createElement("div"); + document.body.appendChild(target); + + const cleanup = markOthers([target], true); + + expect(other.getAttribute("aria-hidden")).toBe("true"); + + cleanup(); + + expect(other.getAttribute("aria-hidden")).toBe(null); +}); + +it("multiple calls", () => { + const other = document.createElement("div"); + document.body.appendChild(other); + const target = document.createElement("div"); + document.body.appendChild(target); + + const cleanup = markOthers([target], true); + + expect(other.getAttribute("aria-hidden")).toBe("true"); + + const nextTarget = document.createElement("div"); + document.body.appendChild(nextTarget); + + const nextCleanup = markOthers([nextTarget], true); + + expect(target.getAttribute("aria-hidden")).toBe("true"); + expect(nextTarget.getAttribute("aria-hidden")).toBe(null); + + document.body.removeChild(nextTarget); + + nextCleanup(); + + expect(target.getAttribute("aria-hidden")).toBe(null); + expect(other.getAttribute("aria-hidden")).toBe("true"); + + cleanup(); + + expect(other.getAttribute("aria-hidden")).toBe(null); + + document.body.appendChild(nextTarget); +}); + +it("out of order cleanup", () => { + const other = document.createElement("div"); + document.body.appendChild(other); + const target = document.createElement("div"); + target.setAttribute("data-testid", ""); + document.body.appendChild(target); + + const cleanup = markOthers([target], true); + + expect(other.getAttribute("aria-hidden")).toBe("true"); + + const nextTarget = document.createElement("div"); + document.body.appendChild(nextTarget); + + const nextCleanup = markOthers([nextTarget], true); + + expect(target.getAttribute("aria-hidden")).toBe("true"); + expect(nextTarget.getAttribute("aria-hidden")).toBe(null); + + cleanup(); + + expect(nextTarget.getAttribute("aria-hidden")).toBe(null); + expect(target.getAttribute("aria-hidden")).toBe("true"); + expect(other.getAttribute("aria-hidden")).toBe("true"); + + nextCleanup(); + + expect(nextTarget.getAttribute("aria-hidden")).toBe(null); + expect(other.getAttribute("aria-hidden")).toBe(null); + expect(target.getAttribute("aria-hidden")).toBe(null); +}); + +it("multiple cleanups with differing controlAttribute", () => { + const other = document.createElement("div"); + document.body.appendChild(other); + const target = document.createElement("div"); + target.setAttribute("data-testid", "1"); + document.body.appendChild(target); + + const cleanup = markOthers([target], true); + + expect(other.getAttribute("aria-hidden")).toBe("true"); + + const target2 = document.createElement("div"); + target.setAttribute("data-testid", "2"); + document.body.appendChild(target2); + + const cleanup2 = markOthers([target2]); + + expect(target.getAttribute("aria-hidden")).not.toBe("true"); + expect(target.getAttribute("data-floating-ui-inert")).toBe(""); + + cleanup(); + + expect(other.getAttribute("aria-hidden")).toBe(null); + + cleanup2(); + + expect(target.getAttribute("data-floating-ui-inert")).toBe(null); +}); From d6f928214a106930e5a23122379d2c06702ee036 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 16:53:06 -0500 Subject: [PATCH 42/87] rearranging for visual test sandbox --- .../floating-focus-manager/floating-focus-manager.svelte | 2 +- .../test/components/floating-arrow.test.ts | 4 ++-- .../floating-focus-manager/floating-focus-manager.test.ts | 2 +- packages/floating-ui-svelte/test/{internal => }/setup.ts | 0 .../test/{ => unit}/hooks/use-click.test.ts | 0 .../test/{ => unit}/hooks/use-client-point.test.ts | 6 +++--- .../test/{ => unit}/hooks/use-dismiss.test.ts | 0 .../test/{ => unit}/hooks/use-floating.test.svelte.ts | 6 +++--- .../test/{ => unit}/hooks/use-focus.test.ts | 0 .../test/{ => unit}/hooks/use-hover.test.ts | 0 .../test/{ => unit}/hooks/use-id.test.ts | 2 +- .../test/{ => unit}/hooks/use-interactions.test.svelte.ts | 4 ++-- .../test/{ => unit}/hooks/use-merge-refs.test.svelte.ts | 6 +++--- .../test/{ => unit}/hooks/use-role.test.ts | 0 .../{ => unit}/hooks/wrapper-components/use-click.svelte | 8 ++++---- .../hooks/wrapper-components/use-client-point.svelte | 2 +- .../hooks/wrapper-components/use-dismiss.svelte | 6 +++--- .../{ => unit}/hooks/wrapper-components/use-focus.svelte | 6 +++--- .../{ => unit}/hooks/wrapper-components/use-hover.svelte | 6 +++--- .../{ => unit}/hooks/wrapper-components/use-role.svelte | 6 +++--- .../floating-ui-svelte/test/{internal => }/utils.d.ts | 0 packages/floating-ui-svelte/test/{internal => }/utils.ts | 0 .../test/{internal => }/with-runes.svelte.ts | 0 packages/floating-ui-svelte/vite.config.ts | 2 +- 24 files changed, 34 insertions(+), 34 deletions(-) rename packages/floating-ui-svelte/test/{internal => }/setup.ts (100%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-click.test.ts (100%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-client-point.test.ts (96%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-dismiss.test.ts (100%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-floating.test.svelte.ts (99%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-focus.test.ts (100%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-hover.test.ts (100%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-id.test.ts (90%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-interactions.test.svelte.ts (94%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-merge-refs.test.svelte.ts (85%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/use-role.test.ts (100%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/wrapper-components/use-click.svelte (75%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/wrapper-components/use-client-point.svelte (97%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/wrapper-components/use-dismiss.svelte (79%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/wrapper-components/use-focus.svelte (79%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/wrapper-components/use-hover.svelte (82%) rename packages/floating-ui-svelte/test/{ => unit}/hooks/wrapper-components/use-role.svelte (81%) rename packages/floating-ui-svelte/test/{internal => }/utils.d.ts (100%) rename packages/floating-ui-svelte/test/{internal => }/utils.ts (100%) rename packages/floating-ui-svelte/test/{internal => }/with-runes.svelte.ts (100%) diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index 8740587a..6c20c811 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -155,7 +155,7 @@ import VisuallyHiddenDismiss from "./visually-hidden-dismiss.svelte"; import { box } from "../../internal/box.svelte.js"; import { reactiveActiveElement } from "../../internal/active-element.svelte.js"; - import { sleep } from "../../../test/internal/utils.js"; + import { sleep } from "../../internal/sleep.js"; let { context, diff --git a/packages/floating-ui-svelte/test/components/floating-arrow.test.ts b/packages/floating-ui-svelte/test/components/floating-arrow.test.ts index 703af71d..e54617cb 100644 --- a/packages/floating-ui-svelte/test/components/floating-arrow.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-arrow.test.ts @@ -1,10 +1,10 @@ import { render, screen } from "@testing-library/svelte"; import { describe, expect, it, vi } from "vitest"; import FloatingArrow from "../../src/components/floating-arrow.svelte"; -import { withRunes } from "../internal/with-runes.svelte.js"; +import { withRunes } from "../with-runes.svelte.js"; import { useFloating } from "../../src/index.js"; -describe("FloatingArrow", () => { +describe.skip("FloatingArrow", () => { vi.mock(import("svelte"), async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts index 5141976f..9c181c26 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/components/floating-focus-manager/floating-focus-manager.test.ts @@ -4,7 +4,7 @@ import { userEvent } from "@testing-library/user-event"; import Main from "./components/main.svelte"; import NestedNested from "./components/nested-nested.svelte"; import DialogNonFocusableRef from "./components/dialog-non-focusable-ref.svelte"; -import { sleep, testKbd } from "../../internal/utils.js"; +import { sleep, testKbd } from "../../utils.js"; import DialogFallbackRef from "./components/dialog-fallback-ref.svelte"; import Combobox from "./components/combobox.svelte"; import FloatingFallback from "./components/floating-fallback.svelte"; diff --git a/packages/floating-ui-svelte/test/internal/setup.ts b/packages/floating-ui-svelte/test/setup.ts similarity index 100% rename from packages/floating-ui-svelte/test/internal/setup.ts rename to packages/floating-ui-svelte/test/setup.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-click.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-click.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-click.test.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-click.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-client-point.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-client-point.test.ts similarity index 96% rename from packages/floating-ui-svelte/test/hooks/use-client-point.test.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-client-point.test.ts index 65b314e3..fcf9cb46 100644 --- a/packages/floating-ui-svelte/test/hooks/use-client-point.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-client-point.test.ts @@ -1,8 +1,8 @@ -import type { Coords } from "../../src/types.js"; -import { screen, fireEvent, render, waitFor } from "@testing-library/svelte"; +import type { Coords } from "../../../src/types.js"; +import { screen, fireEvent, render } from "@testing-library/svelte"; import { expect, it } from "vitest"; import UseClientPoint from "./wrapper-components/use-client-point.svelte"; -import { sleep } from "../internal/utils.js"; +import { sleep } from "../../utils.js"; function expectLocation({ x, y }: Coords) { expect(Number(screen.getByTestId("x")?.textContent)).toBe(x); diff --git a/packages/floating-ui-svelte/test/hooks/use-dismiss.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-dismiss.test.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts b/packages/floating-ui-svelte/test/unit/hooks/use-floating.test.svelte.ts similarity index 99% rename from packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-floating.test.svelte.ts index bd1bbec5..b959fc91 100644 --- a/packages/floating-ui-svelte/test/hooks/use-floating.test.svelte.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-floating.test.svelte.ts @@ -6,9 +6,9 @@ import { offset, } from "@floating-ui/dom"; import { describe, expect, expectTypeOf, it, vi } from "vitest"; -import { type FloatingContext, useFloating } from "../../src/index.js"; -import { useId } from "../../src/index.js"; -import { withRunes } from "../internal/with-runes.svelte.js"; +import { type FloatingContext, useFloating } from "../../../src/index.js"; +import { useId } from "../../../src/index.js"; +import { withRunes } from "../../with-runes.svelte.js"; function createElements(): { reference: Element | null; diff --git a/packages/floating-ui-svelte/test/hooks/use-focus.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-focus.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-focus.test.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-focus.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-hover.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-hover.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-hover.test.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-hover.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/use-id.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-id.test.ts similarity index 90% rename from packages/floating-ui-svelte/test/hooks/use-id.test.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-id.test.ts index 403674c0..85effd38 100644 --- a/packages/floating-ui-svelte/test/hooks/use-id.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-id.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, test } from "vitest"; -import { useId } from "../../src/index.js"; +import { useId } from "../../../src/index.js"; describe("useId", () => { test("returns an id", () => { diff --git a/packages/floating-ui-svelte/test/hooks/use-interactions.test.svelte.ts b/packages/floating-ui-svelte/test/unit/hooks/use-interactions.test.svelte.ts similarity index 94% rename from packages/floating-ui-svelte/test/hooks/use-interactions.test.svelte.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-interactions.test.svelte.ts index 3dc922e1..32b5ca59 100644 --- a/packages/floating-ui-svelte/test/hooks/use-interactions.test.svelte.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-interactions.test.svelte.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { type ElementProps, useInteractions } from "../../src/index.js"; -import { withRunes } from "../internal/with-runes.svelte"; +import { type ElementProps, useInteractions } from "../../../src/index.js"; +import { withRunes } from "../../with-runes.svelte"; describe("useInteractions", () => { it("returns props to the corresponding getter", () => { diff --git a/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts b/packages/floating-ui-svelte/test/unit/hooks/use-merge-refs.test.svelte.ts similarity index 85% rename from packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-merge-refs.test.svelte.ts index 6e4737cd..a80e423a 100644 --- a/packages/floating-ui-svelte/test/hooks/use-merge-refs.test.svelte.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-merge-refs.test.svelte.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { useFloating } from "../../src/index.js"; -import { withRunes } from "../internal/with-runes.svelte.js"; -import { useMergeRefs } from "../../src/hooks/use-merge-refs.svelte.js"; +import { useFloating } from "../../../src/index.js"; +import { withRunes } from "../../with-runes.svelte.js"; +import { useMergeRefs } from "../../../src/hooks/use-merge-refs.svelte.js"; describe("useMergeRefs", () => { vi.mock(import("svelte"), async (importOriginal) => { diff --git a/packages/floating-ui-svelte/test/hooks/use-role.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-role.test.ts similarity index 100% rename from packages/floating-ui-svelte/test/hooks/use-role.test.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-role.test.ts diff --git a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-click.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-click.svelte similarity index 75% rename from packages/floating-ui-svelte/test/hooks/wrapper-components/use-click.svelte rename to packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-click.svelte index 657fe9a1..26540ce5 100644 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-click.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-click.svelte @@ -1,9 +1,9 @@ diff --git a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-wrapper.svelte similarity index 78% rename from packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte rename to packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-wrapper.svelte index e4c9a518..7bbc9822 100644 --- a/packages/floating-ui-svelte/test/components/floating-focus-manager/components/floating-wrapper.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-wrapper.svelte @@ -1,10 +1,10 @@ + +
    Hello world
    diff --git a/packages/floating-ui-svelte/test/visual/app.css b/packages/floating-ui-svelte/test/visual/app.css new file mode 100644 index 00000000..a461c505 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/app.css @@ -0,0 +1 @@ +@import "tailwindcss"; \ No newline at end of file diff --git a/packages/floating-ui-svelte/test/components/button.svelte b/packages/floating-ui-svelte/test/visual/components/button.svelte similarity index 89% rename from packages/floating-ui-svelte/test/components/button.svelte rename to packages/floating-ui-svelte/test/visual/components/button.svelte index 00bc5f50..a6addfdc 100644 --- a/packages/floating-ui-svelte/test/components/button.svelte +++ b/packages/floating-ui-svelte/test/visual/components/button.svelte @@ -1,6 +1,6 @@ diff --git a/packages/floating-ui-svelte/test/components/navigation/main.svelte b/packages/floating-ui-svelte/test/visual/components/navigation/main.svelte similarity index 100% rename from packages/floating-ui-svelte/test/components/navigation/main.svelte rename to packages/floating-ui-svelte/test/visual/components/navigation/main.svelte diff --git a/packages/floating-ui-svelte/test/components/navigation/navigation-item.svelte b/packages/floating-ui-svelte/test/visual/components/navigation/navigation-item.svelte similarity index 86% rename from packages/floating-ui-svelte/test/components/navigation/navigation-item.svelte rename to packages/floating-ui-svelte/test/visual/components/navigation/navigation-item.svelte index 3cacb2b7..0f21986e 100644 --- a/packages/floating-ui-svelte/test/components/navigation/navigation-item.svelte +++ b/packages/floating-ui-svelte/test/visual/components/navigation/navigation-item.svelte @@ -8,11 +8,11 @@ useFocus, useHover, useInteractions, - } from "../../../src/index.js"; + } from "../../../../src/index.js"; import { flip, offset, shift } from "@floating-ui/dom"; - import FloatingNode from "../../../src/components/floating-tree/floating-node.svelte"; - import FloatingPortal from "../../../src/components/floating-portal/floating-portal.svelte"; - import FloatingFocusManager from "../../../src/components/floating-focus-manager/floating-focus-manager.svelte"; + import FloatingNode from "../../../../src/components/floating-tree/floating-node.svelte"; + import FloatingPortal from "../../../../src/components/floating-portal/floating-portal.svelte"; + import FloatingFocusManager from "../../../../src/components/floating-focus-manager/floating-focus-manager.svelte"; let { label, diff --git a/packages/floating-ui-svelte/test/components/navigation/navigation-sub-item.svelte b/packages/floating-ui-svelte/test/visual/components/navigation/navigation-sub-item.svelte similarity index 100% rename from packages/floating-ui-svelte/test/components/navigation/navigation-sub-item.svelte rename to packages/floating-ui-svelte/test/visual/components/navigation/navigation-sub-item.svelte diff --git a/packages/floating-ui-svelte/test/components/navigation/navigation.svelte b/packages/floating-ui-svelte/test/visual/components/navigation/navigation.svelte similarity index 100% rename from packages/floating-ui-svelte/test/components/navigation/navigation.svelte rename to packages/floating-ui-svelte/test/visual/components/navigation/navigation.svelte diff --git a/packages/floating-ui-svelte/test/visual/index.html b/packages/floating-ui-svelte/test/visual/index.html new file mode 100644 index 00000000..a42d2c0e --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + Svelte + TS + + +
    + + + diff --git a/packages/floating-ui-svelte/test/visual/main.ts b/packages/floating-ui-svelte/test/visual/main.ts new file mode 100644 index 00000000..f1bf0ce3 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/main.ts @@ -0,0 +1,10 @@ +import { mount } from "svelte"; +import "./app.css"; +import App from "./App.svelte"; + +const app = mount(App, { + // biome-ignore lint/style/noNonNullAssertion: + target: document.getElementById("app")!, +}); + +export default app; diff --git a/packages/floating-ui-svelte/test/visual/vite-env.d.ts b/packages/floating-ui-svelte/test/visual/vite-env.d.ts new file mode 100644 index 00000000..4078e747 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/floating-ui-svelte/vite.config.ts b/packages/floating-ui-svelte/vite.config.ts index 2ec00a58..dad44597 100644 --- a/packages/floating-ui-svelte/vite.config.ts +++ b/packages/floating-ui-svelte/vite.config.ts @@ -1,12 +1,18 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; import { svelteTesting } from "@testing-library/svelte/vite"; import { defineConfig } from "vitest/config"; +import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ - plugins: [svelte(), svelteTesting()], + server: { + port: 1234, + }, + root: "./test/visual", + plugins: [svelte(), tailwindcss(), svelteTesting()], test: { - include: ["./test/**/*.test.ts"], - setupFiles: ["./test/setup.ts"], + root: "./test/unit", + include: ["./**/*.test.ts"], + setupFiles: ["./setup.ts"], environment: "jsdom", }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed06af54..280045aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,19 +35,22 @@ importers: devDependencies: '@sveltejs/kit': specifier: ^2.15.1 - version: 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + version: 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) '@sveltejs/package': specifier: ^2.3.7 version: 2.3.7(svelte@5.17.3)(typescript@5.7.2) '@sveltejs/vite-plugin-svelte': specifier: ^5.0.3 - version: 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + version: 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) + '@tailwindcss/vite': + specifier: 4.0.0-beta.9 + version: 4.0.0-beta.9(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 '@testing-library/svelte': specifier: ^5.2.6 - version: 5.2.6(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))(vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)) + version: 5.2.6(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))(vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) @@ -63,15 +66,18 @@ importers: svelte: specifier: ^5.17.3 version: 5.17.3 + tailwindcss: + specifier: 4.0.0-beta.9 + version: 4.0.0-beta.9 typescript: specifier: ^5.7.2 version: 5.7.2 vite: specifier: ^6.0.6 - version: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) + version: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) vitest: specifier: ^2.1.8 - version: 2.1.8(@types/node@22.10.5)(jsdom@25.0.1) + version: 2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1) sites/floating-ui-svelte.vercel.app: devDependencies: @@ -80,13 +86,13 @@ importers: version: link:../../packages/floating-ui-svelte '@sveltejs/adapter-vercel': specifier: ^5.5.2 - version: 5.5.2(@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(rollup@4.29.1) + version: 5.5.2(@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(rollup@4.29.1) '@sveltejs/kit': specifier: ^2.15.1 - version: 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + version: 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) '@sveltejs/vite-plugin-svelte': specifier: ^5.0.3 - version: 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + version: 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) @@ -119,10 +125,10 @@ importers: version: 5.7.2 vite: specifier: ^6.0.6 - version: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) + version: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) vite-plugin-pagefind: specifier: ^0.3.0 - version: 0.3.0(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + version: 0.3.0(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) packages: @@ -790,6 +796,84 @@ packages: svelte: ^5.0.0 vite: ^6.0.0 + '@tailwindcss/node@4.0.0-beta.9': + resolution: {integrity: sha512-KuKNhNVU5hd2L5BkXE/twBKkMnHG4wQiHes6axhDbdcRew0/YZtvlWvMIy7QmtBWnR1lM8scPhp0RXmxK/hZdw==} + + '@tailwindcss/oxide-android-arm64@4.0.0-beta.9': + resolution: {integrity: sha512-MiDpTfYvRozM+40mV2wh7GCxyEj7zIOtX3bRNaJgu0adxzZaKkylks46kBY8X91NV3ch6CQSf9Zlr0vi4U5qdw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.0.0-beta.9': + resolution: {integrity: sha512-SjdLul42NElqSHO5uINXylMNDx4KjtN3iB2o5nv0dFJV119DB0rxSCswgSEfigqyMXLyOAw3dwdoJIUFiw5Sdg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.0.0-beta.9': + resolution: {integrity: sha512-pmAs3H+pYUxAYbz2y7Q2tIfcNVlnPiikZN0SejF7JaDROg4PhQsWWpvlzHZZvD6CuyFCRXayudG8PwpJSk29dg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.0.0-beta.9': + resolution: {integrity: sha512-l39LttvdeeueMxuVNn1Z/cNK1YMWNzoIUgTsHCgF2vhY9tl4R+QcSwlviAkvw4AkiAC4El84pGBBVGswyWa8Rw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.0-beta.9': + resolution: {integrity: sha512-sISzLGpVXNqOYJTo7KcdtUWQulZnW7cqFanBNbe8tCkS1KvlIuckC3MWAihLxpLrmobKh/Wv+wB1aE08VEfCww==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.0.0-beta.9': + resolution: {integrity: sha512-8nmeXyBchcqzQtyqjnmMxlLyxBPd+bwlnr5tDr3w6yol0z7Yrfz3T6L4QoZ4TbfhE26t6qWsUa+WQlzMJKsazg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.0.0-beta.9': + resolution: {integrity: sha512-x+Vr4SnZayMj5PEFHL7MczrvjK7fYuv2LvakPfXoDYnAOmjhrjX5go3I0Q65uUPWiZxGcS/y0JgAtQqgHSKU8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.0.0-beta.9': + resolution: {integrity: sha512-4HpvDn3k5P623exDRbo9rjEXcIuHBj3ZV9YcnWJNE9QZ2vzKXGXxCxPuShTAg25JmH8z+b2whmFsnbxDqtgKhA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.0.0-beta.9': + resolution: {integrity: sha512-RgJrSk7uAt5QC7ez0p0uNcd/Z0yoXuBL9VvMnZVdEMDA7dcf1/zMCcFt3p2nGsGY7q2qp0hULdBEhsRP2Gq0cw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-win32-arm64-msvc@4.0.0-beta.9': + resolution: {integrity: sha512-FCpprAxJqDT27C2OaJTAR06+BsmHS2gW7Wu0lC9E6DwiizYP0YjSVFeYvnkluE5O2J4uVR3X2GAaqxbtG4z9Ug==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.0.0-beta.9': + resolution: {integrity: sha512-KOf2YKFwrvFVX+RNJsYVC6tsWBxDMTX7/u4SpUepqkwVgq2yCObx/Sqt820lXuKgGJ9dKsTYF2wvMUGom7B71A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.0.0-beta.9': + resolution: {integrity: sha512-1bpui84CDnrjB6TI3AGR9jYUA28+VIfkrM4BH3+VXA9B80+cARtd3ON06ouA5/r/2xs4qe+T85Z1c0k5X6vLeA==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.0.0-beta.9': + resolution: {integrity: sha512-Hf28QkwSLM6bbOkcTQk1iEEOB37v+9vfqdpHUaLSluZpEGCVAFc0i+p2Gvp6MlK840tyixQu5L39VjL2lAZFFQ==} + peerDependencies: + vite: ^5.2.0 || ^6 + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -1148,6 +1232,11 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} @@ -1189,6 +1278,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.18.0: + resolution: {integrity: sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1439,6 +1532,10 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1462,6 +1559,70 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lightningcss-darwin-arm64@1.29.1: + resolution: {integrity: sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.29.1: + resolution: {integrity: sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.29.1: + resolution: {integrity: sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.29.1: + resolution: {integrity: sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.29.1: + resolution: {integrity: sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.29.1: + resolution: {integrity: sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.29.1: + resolution: {integrity: sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.29.1: + resolution: {integrity: sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.29.1: + resolution: {integrity: sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.29.1: + resolution: {integrity: sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.29.1: + resolution: {integrity: sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1986,6 +2147,13 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tailwindcss@4.0.0-beta.9: + resolution: {integrity: sha512-96KpsfQi+/sFIOfyFnGzyy5pobuzf1iMBD9NVtelerPM/lPI2XUS4Kikw9yuKRniXXw77ov1sl7gCSKLsn6CJA==} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} @@ -2839,9 +3007,9 @@ snapshots: '@shikijs/vscode-textmate@9.3.1': {} - '@sveltejs/adapter-vercel@5.5.2(@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(rollup@4.29.1)': + '@sveltejs/adapter-vercel@5.5.2(@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(rollup@4.29.1)': dependencies: - '@sveltejs/kit': 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + '@sveltejs/kit': 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) '@vercel/nft': 0.27.10(rollup@4.29.1) esbuild: 0.24.2 transitivePeerDependencies: @@ -2849,9 +3017,9 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': + '@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) '@types/cookie': 0.6.0 cookie: 0.6.0 devalue: 5.1.1 @@ -2865,7 +3033,7 @@ snapshots: sirv: 3.0.0 svelte: 5.17.3 tiny-glob: 0.2.9 - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) + vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) '@sveltejs/package@2.3.7(svelte@5.17.3)(typescript@5.7.2)': dependencies: @@ -2878,28 +3046,89 @@ snapshots: transitivePeerDependencies: - typescript - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) debug: 4.4.0 svelte: 5.17.3 - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) + vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))': + '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) debug: 4.4.0 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.17 svelte: 5.17.3 - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) - vitefu: 1.0.4(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)) + vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + vitefu: 1.0.4(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) transitivePeerDependencies: - supports-color + '@tailwindcss/node@4.0.0-beta.9': + dependencies: + enhanced-resolve: 5.18.0 + jiti: 2.4.2 + tailwindcss: 4.0.0-beta.9 + + '@tailwindcss/oxide-android-arm64@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.0.0-beta.9': + optional: true + + '@tailwindcss/oxide@4.0.0-beta.9': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.0.0-beta.9 + '@tailwindcss/oxide-darwin-arm64': 4.0.0-beta.9 + '@tailwindcss/oxide-darwin-x64': 4.0.0-beta.9 + '@tailwindcss/oxide-freebsd-x64': 4.0.0-beta.9 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.0-beta.9 + '@tailwindcss/oxide-linux-arm64-gnu': 4.0.0-beta.9 + '@tailwindcss/oxide-linux-arm64-musl': 4.0.0-beta.9 + '@tailwindcss/oxide-linux-x64-gnu': 4.0.0-beta.9 + '@tailwindcss/oxide-linux-x64-musl': 4.0.0-beta.9 + '@tailwindcss/oxide-win32-arm64-msvc': 4.0.0-beta.9 + '@tailwindcss/oxide-win32-x64-msvc': 4.0.0-beta.9 + + '@tailwindcss/vite@4.0.0-beta.9(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': + dependencies: + '@tailwindcss/node': 4.0.0-beta.9 + '@tailwindcss/oxide': 4.0.0-beta.9 + lightningcss: 1.29.1 + tailwindcss: 4.0.0-beta.9 + vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 @@ -2921,13 +3150,13 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/svelte@5.2.6(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0))(vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1))': + '@testing-library/svelte@5.2.6(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))(vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1))': dependencies: '@testing-library/dom': 10.4.0 svelte: 5.17.3 optionalDependencies: - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) - vitest: 2.1.8(@types/node@22.10.5)(jsdom@25.0.1) + vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + vitest: 2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1) '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: @@ -2984,13 +3213,13 @@ snapshots: chai: 5.1.2 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.5))': + '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.5)(lightningcss@1.29.1))': dependencies: '@vitest/spy': 2.1.8 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 5.4.11(@types/node@22.10.5) + vite: 5.4.11(@types/node@22.10.5)(lightningcss@1.29.1) '@vitest/pretty-format@2.1.8': dependencies: @@ -3239,6 +3468,8 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@1.0.3: {} + detect-libc@2.0.3: {} devalue@5.1.1: {} @@ -3269,6 +3500,11 @@ snapshots: emoji-regex@9.2.2: {} + enhanced-resolve@5.18.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -3577,6 +3813,8 @@ snapshots: jiti@1.21.7: {} + jiti@2.4.2: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -3619,6 +3857,51 @@ snapshots: kleur@4.1.5: {} + lightningcss-darwin-arm64@1.29.1: + optional: true + + lightningcss-darwin-x64@1.29.1: + optional: true + + lightningcss-freebsd-x64@1.29.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.29.1: + optional: true + + lightningcss-linux-arm64-gnu@1.29.1: + optional: true + + lightningcss-linux-arm64-musl@1.29.1: + optional: true + + lightningcss-linux-x64-gnu@1.29.1: + optional: true + + lightningcss-linux-x64-musl@1.29.1: + optional: true + + lightningcss-win32-arm64-msvc@1.29.1: + optional: true + + lightningcss-win32-x64-msvc@1.29.1: + optional: true + + lightningcss@1.29.1: + dependencies: + detect-libc: 1.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.29.1 + lightningcss-darwin-x64: 1.29.1 + lightningcss-freebsd-x64: 1.29.1 + lightningcss-linux-arm-gnueabihf: 1.29.1 + lightningcss-linux-arm64-gnu: 1.29.1 + lightningcss-linux-arm64-musl: 1.29.1 + lightningcss-linux-x64-gnu: 1.29.1 + lightningcss-linux-x64-musl: 1.29.1 + lightningcss-win32-arm64-msvc: 1.29.1 + lightningcss-win32-x64-msvc: 1.29.1 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4152,6 +4435,10 @@ snapshots: transitivePeerDependencies: - ts-node + tailwindcss@4.0.0-beta.9: {} + + tapable@2.2.1: {} + tar@7.4.3: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -4272,13 +4559,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@2.1.8(@types/node@22.10.5): + vite-node@2.1.8(@types/node@22.10.5)(lightningcss@1.29.1): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 1.1.2 - vite: 5.4.11(@types/node@22.10.5) + vite: 5.4.11(@types/node@22.10.5)(lightningcss@1.29.1) transitivePeerDependencies: - '@types/node' - less @@ -4290,13 +4577,13 @@ snapshots: - supports-color - terser - vite-plugin-pagefind@0.3.0(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)): + vite-plugin-pagefind@0.3.0(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)): dependencies: colorette: 2.0.20 valibot: 0.31.0-rc.4 - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) + vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) - vite@5.4.11(@types/node@22.10.5): + vite@5.4.11(@types/node@22.10.5)(lightningcss@1.29.1): dependencies: esbuild: 0.21.5 postcss: 8.4.49 @@ -4304,8 +4591,9 @@ snapshots: optionalDependencies: '@types/node': 22.10.5 fsevents: 2.3.3 + lightningcss: 1.29.1 - vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0): + vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0): dependencies: esbuild: 0.24.2 postcss: 8.4.49 @@ -4313,17 +4601,18 @@ snapshots: optionalDependencies: '@types/node': 22.10.5 fsevents: 2.3.3 - jiti: 1.21.7 + jiti: 2.4.2 + lightningcss: 1.29.1 yaml: 2.7.0 - vitefu@1.0.4(vite@6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0)): + vitefu@1.0.4(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)): optionalDependencies: - vite: 6.0.6(@types/node@22.10.5)(jiti@1.21.7)(yaml@2.7.0) + vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) - vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1): + vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1): dependencies: '@vitest/expect': 2.1.8 - '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.5)) + '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.5)(lightningcss@1.29.1)) '@vitest/pretty-format': 2.1.8 '@vitest/runner': 2.1.8 '@vitest/snapshot': 2.1.8 @@ -4339,8 +4628,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 1.2.0 - vite: 5.4.11(@types/node@22.10.5) - vite-node: 2.1.8(@types/node@22.10.5) + vite: 5.4.11(@types/node@22.10.5)(lightningcss@1.29.1) + vite-node: 2.1.8(@types/node@22.10.5)(lightningcss@1.29.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.10.5 From 88783e3fef6e160bc8b7a87cc871584aea35516b Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 17:49:23 -0500 Subject: [PATCH 44/87] visual testing --- package.json | 5 +- packages/floating-ui-svelte/package.json | 8 +- .../src/hooks/use-floating.svelte.ts | 2 +- packages/floating-ui-svelte/svelte.config.js | 7 + .../floating-ui-svelte/test/visual/App.svelte | 4 - .../floating-ui-svelte/test/visual/app.css | 14 +- .../floating-ui-svelte/test/visual/app.d.ts | 13 ++ .../floating-ui-svelte/test/visual/app.html | 12 ++ .../test/visual/components/arrow/demo.svelte | 83 +++++++++++ .../floating-ui-svelte/test/visual/index.html | 13 -- .../floating-ui-svelte/test/visual/main.ts | 10 -- .../test/visual/routes/+layout.svelte | 56 +++++++ .../test/visual/routes/+page.svelte | 5 + .../test/visual/routes/arrow/+page.svelte | 141 ++++++++++++++++++ .../test/visual/vite-env.d.ts | 2 - packages/floating-ui-svelte/tsconfig.json | 19 ++- packages/floating-ui-svelte/vite.config.ts | 7 +- pnpm-lock.yaml | 132 +++++++++++++--- 18 files changed, 475 insertions(+), 58 deletions(-) delete mode 100644 packages/floating-ui-svelte/test/visual/App.svelte create mode 100644 packages/floating-ui-svelte/test/visual/app.d.ts create mode 100644 packages/floating-ui-svelte/test/visual/app.html create mode 100644 packages/floating-ui-svelte/test/visual/components/arrow/demo.svelte delete mode 100644 packages/floating-ui-svelte/test/visual/index.html delete mode 100644 packages/floating-ui-svelte/test/visual/main.ts create mode 100644 packages/floating-ui-svelte/test/visual/routes/+layout.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/+page.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/arrow/+page.svelte delete mode 100644 packages/floating-ui-svelte/test/visual/vite-env.d.ts diff --git a/package.json b/package.json index a1b438ad..3a206a9a 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,14 @@ "name": "monorepo", "private": true, "scripts": { - "dev": "pnpm -F \"./packages/**\" svelte-kit sync && pnpm -r --parallel --reporter append-only --color dev", + "dev": "pnpm -F \"./packages/**\" sync && pnpm -r --parallel --reporter append-only --color dev", "format": "biome check . --write", "format:check": "biome check .", "build": "pnpm --recursive build", "build:watch": "pnpm --recursive --parallel build:watch", "test": "pnpm --recursive --reporter append-only --color test", "test:watch": "pnpm --recursive --reporter append-only --color test:watch", - "sync": "pnpm --recursive sync", - "postinstall": "pnpm sync" + "sync": "pnpm --recursive sync" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/packages/floating-ui-svelte/package.json b/packages/floating-ui-svelte/package.json index 79aac096..ae44a96c 100644 --- a/packages/floating-ui-svelte/package.json +++ b/packages/floating-ui-svelte/package.json @@ -6,12 +6,14 @@ "build": "svelte-package --input ./src", "build:watch": "pnpm build --watch", "test": "vitest run", - "dev:visual": "vite", + "dev:visual": "vite dev", "dev:build": "pnpm build:watch", "test:watch": "pnpm test --watch", "sync": "svelte-kit sync && pnpm build" }, - "files": ["dist"], + "files": [ + "dist" + ], "sideEffects": false, "svelte": "./dist/index.js", "types": "./dist/index.d.ts", @@ -44,7 +46,7 @@ "svelte": "^5.17.3", "tailwindcss": "4.0.0-beta.9", "typescript": "^5.7.2", - "vite": "^6.0.6", + "vite": "6.0.7", "vitest": "^2.1.8" }, "dependencies": { diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index 48c4061f..e3afae62 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -37,7 +37,7 @@ interface UseFloatingOptions { * Where to place the floating element relative to its reference element. * @default 'bottom' */ - placement?: MaybeGetter; + placement?: MaybeGetter; /** * The type of CSS position property to use. diff --git a/packages/floating-ui-svelte/svelte.config.js b/packages/floating-ui-svelte/svelte.config.js index 40126808..169e5230 100644 --- a/packages/floating-ui-svelte/svelte.config.js +++ b/packages/floating-ui-svelte/svelte.config.js @@ -4,6 +4,13 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: [vitePreprocess()], + kit: { + files: { + routes: "./test/visual/routes", + appTemplate: "./test/visual/app.html", + params: "./test/visual/params", + }, + }, }; export default config; diff --git a/packages/floating-ui-svelte/test/visual/App.svelte b/packages/floating-ui-svelte/test/visual/App.svelte deleted file mode 100644 index 6feb95ee..00000000 --- a/packages/floating-ui-svelte/test/visual/App.svelte +++ /dev/null @@ -1,4 +0,0 @@ - - -
    Hello world
    diff --git a/packages/floating-ui-svelte/test/visual/app.css b/packages/floating-ui-svelte/test/visual/app.css index a461c505..963a6edc 100644 --- a/packages/floating-ui-svelte/test/visual/app.css +++ b/packages/floating-ui-svelte/test/visual/app.css @@ -1 +1,13 @@ -@import "tailwindcss"; \ No newline at end of file +@import "tailwindcss"; + +button { + @apply cursor-default; + } + + .scrollbar-none::-webkit-scrollbar { + display: none; + } + .scrollbar-none { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } diff --git a/packages/floating-ui-svelte/test/visual/app.d.ts b/packages/floating-ui-svelte/test/visual/app.d.ts new file mode 100644 index 00000000..743f07b2 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/packages/floating-ui-svelte/test/visual/app.html b/packages/floating-ui-svelte/test/visual/app.html new file mode 100644 index 00000000..f22aeaad --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
    %sveltekit.body%
    + + diff --git a/packages/floating-ui-svelte/test/visual/components/arrow/demo.svelte b/packages/floating-ui-svelte/test/visual/components/arrow/demo.svelte new file mode 100644 index 00000000..b55e5912 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/arrow/demo.svelte @@ -0,0 +1,83 @@ + + +
    + + {f.placement} + + {#if open} +
    + {#if children} + {@render children?.()} + {:else} + Tooltip + {/if} + +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/visual/index.html b/packages/floating-ui-svelte/test/visual/index.html deleted file mode 100644 index a42d2c0e..00000000 --- a/packages/floating-ui-svelte/test/visual/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + Svelte + TS - - -
    - - - diff --git a/packages/floating-ui-svelte/test/visual/main.ts b/packages/floating-ui-svelte/test/visual/main.ts deleted file mode 100644 index f1bf0ce3..00000000 --- a/packages/floating-ui-svelte/test/visual/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { mount } from "svelte"; -import "./app.css"; -import App from "./App.svelte"; - -const app = mount(App, { - // biome-ignore lint/style/noNonNullAssertion: - target: document.getElementById("app")!, -}); - -export default app; diff --git a/packages/floating-ui-svelte/test/visual/routes/+layout.svelte b/packages/floating-ui-svelte/test/visual/routes/+layout.svelte new file mode 100644 index 00000000..4bd74526 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/+layout.svelte @@ -0,0 +1,56 @@ + + +
    +
    + {@render children?.()} +
    + +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/+page.svelte new file mode 100644 index 00000000..db55647c --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/+page.svelte @@ -0,0 +1,5 @@ +

    Floating UI Testing Grounds

    +

    + Welcome! On the left is a navigation bar to browse through different testing + files. +

    diff --git a/packages/floating-ui-svelte/test/visual/routes/arrow/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/arrow/+page.svelte new file mode 100644 index 00000000..c04dafe3 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/arrow/+page.svelte @@ -0,0 +1,141 @@ + + + + +

    Arrow

    +

    Slight transparency

    +
    + {#each allPlacements as placement (placement)} + + {/each} +
    +

    {"tipRadius={2}"}

    +
    + {#each allPlacements as placement (placement)} + + {/each} +
    +

    {"tipRadius={5}"}

    +
    + {#each allPlacements as placement (placement)} + + {/each} +
    +

    Transparent stroke + tipRadius

    +
    + {#each allPlacements as placement (placement)} + + {/each} +
    +

    Custom path + transparent stroke

    +
    + {#each allPlacements as placement (placement)} + + {/each} +
    +

    + Tailwind classs for fill and stroke +

    +
    + {#each allPlacements as placement (placement)} + path:first-of-type]:stroke-pink-500 [&>path:last-of-type]:stroke-white", + strokeWidth: 1, + }} + floatingProps={{ + class: "border border-pink-500 text-pink-500 bg-white p-2", + }} /> + {/each} +
    +

    Arrow with shift()

    +
    + {#each allPlacements as placement (placement)} + + {"0123456789 ".repeat(40)} + + {/each} +
    +

    Arrow with autoPlacement()

    +
    + {#each allPlacements as placement (placement)} + + {/each} +
    diff --git a/packages/floating-ui-svelte/test/visual/vite-env.d.ts b/packages/floating-ui-svelte/test/visual/vite-env.d.ts deleted file mode 100644 index 4078e747..00000000 --- a/packages/floating-ui-svelte/test/visual/vite-env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/packages/floating-ui-svelte/tsconfig.json b/packages/floating-ui-svelte/tsconfig.json index 666df877..3a2eeeee 100644 --- a/packages/floating-ui-svelte/tsconfig.json +++ b/packages/floating-ui-svelte/tsconfig.json @@ -13,5 +13,22 @@ "moduleResolution": "NodeNext", "types": ["svelte"] }, - "include": ["src", "test"] + "include": [ + "ambient.d.ts", + "non-ambient.d.ts", + "./.svelte-kit/types/**/$types.d.ts", + "./vite.config.js", + "./vite.config.ts", + "./test/visual/routes/**/*.js", + "./test/visual/routes/**/*.ts", + "./test/visual/routes/**/*.svelte", + "./src/**/*.js", + "./src/**/*.ts", + "./src/**/*.svelte", + "./tests/**/*.js", + "./tests/**/*.ts", + "./tests/**/*.svelte", + "src", + "test" + ] } diff --git a/packages/floating-ui-svelte/vite.config.ts b/packages/floating-ui-svelte/vite.config.ts index dad44597..4c74106d 100644 --- a/packages/floating-ui-svelte/vite.config.ts +++ b/packages/floating-ui-svelte/vite.config.ts @@ -1,14 +1,17 @@ -import { svelte } from "@sveltejs/vite-plugin-svelte"; import { svelteTesting } from "@testing-library/svelte/vite"; import { defineConfig } from "vitest/config"; import tailwindcss from "@tailwindcss/vite"; +import { sveltekit } from "@sveltejs/kit/vite"; export default defineConfig({ server: { port: 1234, + fs: { + strict: false, + }, }, root: "./test/visual", - plugins: [svelte(), tailwindcss(), svelteTesting()], + plugins: [sveltekit(), tailwindcss(), svelteTesting()], test: { root: "./test/unit", include: ["./**/*.test.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 280045aa..73406e88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,22 +35,22 @@ importers: devDependencies: '@sveltejs/kit': specifier: ^2.15.1 - version: 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) + version: 2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) '@sveltejs/package': specifier: ^2.3.7 version: 2.3.7(svelte@5.17.3)(typescript@5.7.2) '@sveltejs/vite-plugin-svelte': specifier: ^5.0.3 - version: 5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) + version: 5.0.3(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) '@tailwindcss/vite': specifier: 4.0.0-beta.9 - version: 4.0.0-beta.9(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) + version: 4.0.0-beta.9(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 '@testing-library/svelte': specifier: ^5.2.6 - version: 5.2.6(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))(vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1)) + version: 5.2.6(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))(vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) @@ -73,8 +73,8 @@ importers: specifier: ^5.7.2 version: 5.7.2 vite: - specifier: ^6.0.6 - version: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + specifier: 6.0.7 + version: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) vitest: specifier: ^2.1.8 version: 2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1) @@ -2284,8 +2284,8 @@ packages: peerDependencies: vite: ^6.0.0 - vite@5.4.11: - resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} + vite@5.4.8: + resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -2355,6 +2355,46 @@ packages: yaml: optional: true + vite@6.0.7: + resolution: {integrity: sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.0.4: resolution: {integrity: sha512-y6zEE3PQf6uu/Mt6DTJ9ih+kyJLr4XcSgHR2zUkM8SWDhuixEJxfJ6CZGMHh1Ec3vPLoEA0IHU5oWzVqw8ulow==} peerDependencies: @@ -3035,6 +3075,24 @@ snapshots: tiny-glob: 0.2.9 vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + '@sveltejs/kit@2.15.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) + '@types/cookie': 0.6.0 + cookie: 0.6.0 + devalue: 5.1.1 + esm-env: 1.2.1 + import-meta-resolve: 4.1.0 + kleur: 4.1.5 + magic-string: 0.30.17 + mrmime: 2.0.0 + sade: 1.8.1 + set-cookie-parser: 2.7.1 + sirv: 3.0.0 + svelte: 5.17.3 + tiny-glob: 0.2.9 + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + '@sveltejs/package@2.3.7(svelte@5.17.3)(typescript@5.7.2)': dependencies: chokidar: 4.0.3 @@ -3055,6 +3113,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) + debug: 4.4.0 + svelte: 5.17.3 + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + transitivePeerDependencies: + - supports-color + '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': dependencies: '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) @@ -3068,6 +3135,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)))(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) + debug: 4.4.0 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.17 + svelte: 5.17.3 + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + vitefu: 1.0.4(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) + transitivePeerDependencies: + - supports-color + '@tailwindcss/node@4.0.0-beta.9': dependencies: enhanced-resolve: 5.18.0 @@ -3121,13 +3201,13 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.0.0-beta.9 '@tailwindcss/oxide-win32-x64-msvc': 4.0.0-beta.9 - '@tailwindcss/vite@4.0.0-beta.9(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': + '@tailwindcss/vite@4.0.0-beta.9(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))': dependencies: '@tailwindcss/node': 4.0.0-beta.9 '@tailwindcss/oxide': 4.0.0-beta.9 lightningcss: 1.29.1 tailwindcss: 4.0.0-beta.9 - vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) '@testing-library/dom@10.4.0': dependencies: @@ -3150,12 +3230,12 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/svelte@5.2.6(svelte@5.17.3)(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))(vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1))': + '@testing-library/svelte@5.2.6(svelte@5.17.3)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0))(vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1))': dependencies: '@testing-library/dom': 10.4.0 svelte: 5.17.3 optionalDependencies: - vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) vitest: 2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1) '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': @@ -3213,13 +3293,13 @@ snapshots: chai: 5.1.2 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.5)(lightningcss@1.29.1))': + '@vitest/mocker@2.1.8(vite@5.4.8(@types/node@22.10.5)(lightningcss@1.29.1))': dependencies: '@vitest/spy': 2.1.8 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 5.4.11(@types/node@22.10.5)(lightningcss@1.29.1) + vite: 5.4.8(@types/node@22.10.5)(lightningcss@1.29.1) '@vitest/pretty-format@2.1.8': dependencies: @@ -4565,7 +4645,7 @@ snapshots: debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 1.1.2 - vite: 5.4.11(@types/node@22.10.5)(lightningcss@1.29.1) + vite: 5.4.8(@types/node@22.10.5)(lightningcss@1.29.1) transitivePeerDependencies: - '@types/node' - less @@ -4583,7 +4663,7 @@ snapshots: valibot: 0.31.0-rc.4 vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) - vite@5.4.11(@types/node@22.10.5)(lightningcss@1.29.1): + vite@5.4.8(@types/node@22.10.5)(lightningcss@1.29.1): dependencies: esbuild: 0.21.5 postcss: 8.4.49 @@ -4605,14 +4685,30 @@ snapshots: lightningcss: 1.29.1 yaml: 2.7.0 + vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0): + dependencies: + esbuild: 0.24.2 + postcss: 8.4.49 + rollup: 4.29.1 + optionalDependencies: + '@types/node': 22.10.5 + fsevents: 2.3.3 + jiti: 2.4.2 + lightningcss: 1.29.1 + yaml: 2.7.0 + vitefu@1.0.4(vite@6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)): optionalDependencies: vite: 6.0.6(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + vitefu@1.0.4(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)): + optionalDependencies: + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0) + vitest@2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1): dependencies: '@vitest/expect': 2.1.8 - '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.5)(lightningcss@1.29.1)) + '@vitest/mocker': 2.1.8(vite@5.4.8(@types/node@22.10.5)(lightningcss@1.29.1)) '@vitest/pretty-format': 2.1.8 '@vitest/runner': 2.1.8 '@vitest/snapshot': 2.1.8 @@ -4628,7 +4724,7 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 1.2.0 - vite: 5.4.11(@types/node@22.10.5)(lightningcss@1.29.1) + vite: 5.4.8(@types/node@22.10.5)(lightningcss@1.29.1) vite-node: 2.1.8(@types/node@22.10.5)(lightningcss@1.29.1) why-is-node-running: 2.3.0 optionalDependencies: From 1832e4b2271b36072eaedcd45394810be6d0e811 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 17:54:07 -0500 Subject: [PATCH 45/87] init tooltip visual test --- .../components/floating-delay-group.svelte | 2 +- .../visual/components/tooltip/tooltip.svelte | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte diff --git a/packages/floating-ui-svelte/src/components/floating-delay-group.svelte b/packages/floating-ui-svelte/src/components/floating-delay-group.svelte index f78c42df..bce31cb3 100644 --- a/packages/floating-ui-svelte/src/components/floating-delay-group.svelte +++ b/packages/floating-ui-svelte/src/components/floating-delay-group.svelte @@ -168,7 +168,7 @@ timeoutMs?: number; } - export type { FloatingDelayGroupProps, UseDelayGroupOptions }; + export type { FloatingDelayGroupProps, UseDelayGroupOptions, Delay }; export { useDelayGroup }; diff --git a/packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte b/packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte new file mode 100644 index 00000000..e25c29fc --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte @@ -0,0 +1,51 @@ + From 093b8661584cf99ad2925f2e7df2284bab5fe15d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 18:35:25 -0500 Subject: [PATCH 46/87] tooltip visual testing --- .../src/hooks/use-hover.svelte.ts | 10 +- .../src/hooks/use-transition.svelte.ts | 257 ++++++++++++++++++ packages/floating-ui-svelte/src/index.ts | 1 + .../visual/components/tooltip/main.svelte | 37 +++ .../visual/components/tooltip/tooltip.svelte | 67 ++++- .../test/visual/routes/tooltip/+page.svelte | 5 + 6 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts create mode 100644 packages/floating-ui-svelte/test/visual/components/tooltip/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/tooltip/+page.svelte diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index c9287327..55466c19 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -112,11 +112,11 @@ function getDelay( } class HoverInteraction implements ElementProps { - #enabled = $derived.by(() => extract(this.options.enabled ?? true)); - #mouseOnly = $derived.by(() => extract(this.options.mouseOnly ?? false)); - #delay = $derived.by(() => extract(this.options.delay ?? 0)); - #restMs = $derived.by(() => extract(this.options.restMs ?? 0)); - #move = $derived.by(() => extract(this.options.move ?? true)); + #enabled = $derived.by(() => extract(this.options.enabled, true)); + #mouseOnly = $derived.by(() => extract(this.options.mouseOnly, false)); + #delay = $derived.by(() => extract(this.options.delay, 0)); + #restMs = $derived.by(() => extract(this.options.restMs, 0)); + #move = $derived.by(() => extract(this.options.move, true)); #handleClose = $state(null); #tree: FloatingTreeType | null = null; #parentId: string | null = null; diff --git a/packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts new file mode 100644 index 00000000..4f508fa0 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts @@ -0,0 +1,257 @@ +import type { PropertiesHyphen } from "csstype"; +import { extract } from "../internal/extract.js"; +import { watch } from "../internal/watch.svelte.js"; +import type { Boxed, Getter, MaybeGetter, ReferenceType } from "../types.js"; +import type { FloatingContext } from "./use-floating.svelte.js"; +import type { Placement, Side } from "@floating-ui/utils"; +import { styleObjectToString } from "../internal/style-object-to-string.js"; + +function execWithArgsOrReturn( + valueOrFn: Value | ((args: SidePlacement) => Value), + args: SidePlacement, +): Value { + return typeof valueOrFn === "function" ? valueOrFn(args) : valueOrFn; +} + +type UseDelayUnmountOptions = { + open: Getter; + durationMs: Getter; +}; + +function useDelayUnmount(options: UseDelayUnmountOptions): Boxed { + const open = $derived(extract(options.open)); + const durationMs = $derived(extract(options.durationMs)); + + let isMounted = $state(open); + + $effect(() => { + if (open && !isMounted) { + isMounted = true; + } + }); + + $effect(() => { + if (!open && isMounted) { + const timeout = setTimeout(() => { + isMounted = false; + }, durationMs); + return () => clearTimeout(timeout); + } + }); + + return { + get current() { + return isMounted; + }, + }; +} + +interface UseTransitionStatusOptions { + /** + * The duration of the transition in milliseconds, or an object containing + * `open` and `close` keys for different durations. + */ + duration?: MaybeGetter; +} +type TransitionStatus = "unmounted" | "initial" | "open" | "close"; + +class TransitionStatusState { + #duration = $derived.by(() => extract(this.options.duration, 250)); + #closeDuration = $derived.by(() => { + if (typeof this.#duration === "number") { + return this.#duration; + } + return this.#duration.close || 0; + }); + #status: TransitionStatus = $state("unmounted"); + #isMounted: ReturnType; + + constructor( + private readonly context: FloatingContext, + private readonly options: UseTransitionStatusOptions, + ) { + this.#isMounted = useDelayUnmount({ + open: () => this.context.open, + durationMs: () => this.#closeDuration, + }); + + $effect.pre(() => { + if (!this.#isMounted.current && this.#status === "close") { + this.#status = "unmounted"; + } + }); + + watch.pre([() => this.context.open, () => this.context.floating], () => { + if (!this.context.floating) return; + + if (this.context.open) { + this.#status = "initial"; + + const frame = requestAnimationFrame(() => { + this.#status = "open"; + }); + + return () => { + cancelAnimationFrame(frame); + }; + } + + this.#status = "close"; + }); + } + + get isMounted() { + return this.#isMounted.current; + } + + get status() { + return this.#status; + } +} + +/** + * Provides a status string to apply CSS transitions to a floating element, + * correctly handling placement-aware transitions. + */ +function useTransitionStatus( + context: FloatingContext, + options: UseTransitionStatusOptions = {}, +): TransitionStatusState { + return new TransitionStatusState(context, options); +} + +type CSSStylesProperty = + | PropertiesHyphen + | ((params: { side: Side; placement: Placement }) => PropertiesHyphen); + +interface UseTransitionStylesOptions extends UseTransitionStatusOptions { + /** + * The styles to apply when the floating element is initially mounted. + */ + initial?: CSSStylesProperty; + /** + * The styles to apply when the floating element is transitioning to the + * `open` state. + */ + open?: CSSStylesProperty; + /** + * The styles to apply when the floating element is transitioning to the + * `close` state. + */ + close?: CSSStylesProperty; + /** + * The styles to apply to all states. + */ + common?: CSSStylesProperty; +} + +class TransitionStylesState { + #initial = $derived.by(() => this.options.initial ?? { opacity: 0 }); + #open = $derived.by(() => this.options.open); + #close = $derived.by(() => this.options.close); + #common = $derived.by(() => this.options.common); + #duration = $derived.by(() => extract(this.options.duration, 250)); + #placement = $derived.by(() => this.context.placement); + #side = $derived.by(() => this.#placement.split("-")[0] as Side); + #fnArgs = $derived.by(() => ({ + side: this.#side, + placement: this.#placement, + })); + #openDuration = $derived.by(() => { + if (typeof this.#duration === "number") { + return this.#duration; + } + return this.#duration.open || 0; + }); + #closeDuration = $derived.by(() => { + if (typeof this.#duration === "number") { + return this.#duration; + } + return this.#duration.close || 0; + }); + #styles = $state.raw({}); + #transitionStatus: TransitionStatusState; + #status = $derived.by(() => this.#transitionStatus.status); + + constructor( + private readonly context: FloatingContext, + private readonly options: UseTransitionStylesOptions = {}, + ) { + this.#styles = { + ...execWithArgsOrReturn(this.#common, this.#fnArgs), + ...execWithArgsOrReturn(this.#initial, this.#fnArgs), + }; + this.#transitionStatus = useTransitionStatus(context, { + duration: this.options.duration, + }); + + watch.pre( + [ + () => this.#closeDuration, + () => this.#close, + () => this.#initial, + () => this.#open, + () => this.#common, + () => this.#openDuration, + () => this.#status, + () => this.#fnArgs, + ], + () => { + const initialStyles = execWithArgsOrReturn(this.#initial, this.#fnArgs); + const closeStyles = execWithArgsOrReturn(this.#close, this.#fnArgs); + const commonStyles = execWithArgsOrReturn(this.#common, this.#fnArgs); + const openStyles = + execWithArgsOrReturn(this.#open, this.#fnArgs) || + Object.keys(initialStyles).reduce((acc: Record, key) => { + acc[key] = ""; + return acc; + }, {}); + + if (this.#status === "initial") { + this.#styles = { + "transition-property": this.#styles["transition-property"], + ...commonStyles, + ...initialStyles, + }; + } + + if (this.#status === "open") { + this.#styles = { + "transition-property": Object.keys(openStyles).join(", "), + "transition-duration": `${this.#openDuration}ms`, + ...commonStyles, + ...openStyles, + }; + } + + if (this.#status === "close") { + const localStyles = closeStyles || initialStyles; + this.#styles = { + "transition-property": Object.keys(localStyles).join(", "), + "transition-duration": `${this.#closeDuration}ms`, + ...commonStyles, + ...localStyles, + }; + } + }, + ); + } + + get styles() { + return styleObjectToString(this.#styles); + } + + get isMounted() { + return this.#transitionStatus.isMounted; + } +} + +function useTransitionStyles( + context: FloatingContext, + options: UseTransitionStylesOptions = {}, +): TransitionStylesState { + return new TransitionStylesState(context, options); +} + +export { useTransitionStyles, useTransitionStatus }; +export type { UseTransitionStatusOptions, UseTransitionStylesOptions }; diff --git a/packages/floating-ui-svelte/src/index.ts b/packages/floating-ui-svelte/src/index.ts index 7625bc05..e8984bf6 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -35,6 +35,7 @@ export * from "./hooks/use-hover.svelte.js"; export * from "./hooks/use-id.js"; export * from "./hooks/use-interactions.svelte.js"; export * from "./hooks/use-role.svelte.js"; +export * from "./hooks/use-transition.svelte.js"; export * from "./safe-polygon.js"; /** diff --git a/packages/floating-ui-svelte/test/visual/components/tooltip/main.svelte b/packages/floating-ui-svelte/test/visual/components/tooltip/main.svelte new file mode 100644 index 00000000..342e312e --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/tooltip/main.svelte @@ -0,0 +1,37 @@ + + +

    Tooltip

    +
    + + {#snippet children(ref, props)} + + {/snippet} + +
    +
    +
    + + + {#snippet children(ref, props)} + + {/snippet} + + + {#snippet children(ref, props)} + + {/snippet} + + + {#snippet children(ref, props)} + + {/snippet} + + +
    +
    diff --git a/packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte b/packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte index e25c29fc..8bc3678f 100644 --- a/packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte +++ b/packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte @@ -8,10 +8,16 @@ useHover, useInteractions, useRole, + useTransitionStyles, type Delay, } from "../../../../src/index.js"; import type { Snippet } from "svelte"; import { autoUpdate, flip, offset, shift } from "@floating-ui/dom"; + import { + box, + type WritableBox, + } from "../../../../src/internal/box.svelte.js"; + import FloatingPortal from "../../../../src/components/floating-portal/floating-portal.svelte"; let { label, @@ -22,12 +28,20 @@ label: string; placement?: Placement; delay?: Delay; - children?: Snippet; + children?: Snippet< + [ref: WritableBox, props: Record] + >; } = $props(); let open = $state(false); + const reference = box(null); + const f = useFloating({ + reference: () => reference.current, + onReferenceChange: (v) => { + reference.current = v; + }, open: () => open, onOpenChange: (v) => { open = v; @@ -48,4 +62,55 @@ useRole(f.context, { role: "tooltip" }), useDismiss(f.context), ]); + + const instantDuration = 0; + const openDuration = 750; + const closeDuration = 250; + + const transitions = useTransitionStyles(f.context, { + duration: () => + delayGroup.isInstantPhase + ? { + open: openDuration, + close: + delayGroup.currentId === f.context.floatingId + ? closeDuration + : instantDuration, + } + : { + open: openDuration, + close: closeDuration, + }, + initial: { + opacity: 0, + scale: "0.925", + }, + common: ({ side }) => ({ + "transition-timing-function": "cubic-bezier(.18,.87,.4,.97)", + "transform-origin": { + top: "bottom", + left: "right", + bottom: "top", + right: "left", + }[side], + }), + }); + +{@render children?.(reference, ints.getReferenceProps())} + +{#if transitions.isMounted} + +
    +
    + {label} +
    +
    +
    +{/if} diff --git a/packages/floating-ui-svelte/test/visual/routes/tooltip/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/tooltip/+page.svelte new file mode 100644 index 00000000..e8b39e65 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/tooltip/+page.svelte @@ -0,0 +1,5 @@ + + +
    From e659564748a6653733d71b0ff371e075212b77df Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 18:50:47 -0500 Subject: [PATCH 47/87] popover visual testing --- packages/floating-ui-svelte/package.json | 2 + .../visual/components/popover/main.svelte | 63 +++++++++++ .../visual/components/popover/popover.svelte | 102 ++++++++++++++++++ .../test/visual/routes/popover/+page.svelte | 5 + pnpm-lock.yaml | 69 ++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 packages/floating-ui-svelte/test/visual/components/popover/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/popover/popover.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/popover/+page.svelte diff --git a/packages/floating-ui-svelte/package.json b/packages/floating-ui-svelte/package.json index ae44a96c..bc9d4186 100644 --- a/packages/floating-ui-svelte/package.json +++ b/packages/floating-ui-svelte/package.json @@ -40,8 +40,10 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/svelte": "^5.2.6", "@testing-library/user-event": "^14.5.2", + "bits-ui": "1.0.0-next.78", "clsx": "^2.1.1", "csstype": "^3.1.3", + "lucide-svelte": "^0.469.0", "resize-observer-polyfill": "^1.5.1", "svelte": "^5.17.3", "tailwindcss": "4.0.0-beta.9", diff --git a/packages/floating-ui-svelte/test/visual/components/popover/main.svelte b/packages/floating-ui-svelte/test/visual/components/popover/main.svelte new file mode 100644 index 00000000..72070636 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/popover/main.svelte @@ -0,0 +1,63 @@ + + +

    Popover

    +
    + + {#snippet children(ref, props)} + + {/snippet} + {#snippet content({ labelId, descriptionId, close })} +

    Title

    +

    Description

    + + {#snippet content({ labelId, descriptionId, close })} +

    Title

    +

    Description

    + + {#snippet children(ref, props)} + + {/snippet} + {#snippet content({ labelId, descriptionId, close })} +

    + Title +

    +

    Description

    + + {/snippet} +
    + + {/snippet} + {#snippet children(ref, props)} + + {/snippet} +
    + + + {/snippet} +
    +
    + + diff --git a/packages/floating-ui-svelte/test/visual/components/popover/popover.svelte b/packages/floating-ui-svelte/test/visual/components/popover/popover.svelte new file mode 100644 index 00000000..5ff7d24f --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/popover/popover.svelte @@ -0,0 +1,102 @@ + + + + {@render children?.( + ref, + ints.getReferenceProps({ "data-open": open ? "" : undefined }) + )} + + {#if open} + + +
    + {@render content({ + labelId, + descriptionId, + close: () => (open = false), + })} +
    +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/popover/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/popover/+page.svelte new file mode 100644 index 00000000..b02ba659 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/popover/+page.svelte @@ -0,0 +1,5 @@ + + +
    diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73406e88..c631719f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,12 +54,18 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) + bits-ui: + specifier: 1.0.0-next.78 + version: 1.0.0-next.78(svelte@5.17.3) clsx: specifier: ^2.1.1 version: 2.1.1 csstype: specifier: ^3.1.3 version: 3.1.3 + lucide-svelte: + specifier: ^0.469.0 + version: 0.469.0(svelte@5.17.3) resize-observer-polyfill: specifier: ^1.5.1 version: 1.5.1 @@ -560,6 +566,9 @@ packages: '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@internationalized/date@3.7.0': + resolution: {integrity: sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -796,6 +805,9 @@ packages: svelte: ^5.0.0 vite: ^6.0.0 + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tailwindcss/node@4.0.0-beta.9': resolution: {integrity: sha512-KuKNhNVU5hd2L5BkXE/twBKkMnHG4wQiHes6axhDbdcRew0/YZtvlWvMIy7QmtBWnR1lM8scPhp0RXmxK/hZdw==} @@ -1068,6 +1080,12 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bits-ui@1.0.0-next.78: + resolution: {integrity: sha512-jZjG2ObZ/CNyCNaXecpItC7hRXqJAgEfMhr06/eNrf3wHiiPyhdcy4OkzLcJyxeOrDyj+xma8cZTd3JRWqJdAw==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.11.0 + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -2011,6 +2029,16 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + runed@0.20.0: + resolution: {integrity: sha512-YqPxaUdWL5nUXuSF+/v8a+NkVN8TGyEGbQwTA25fLY35MR/2bvZ1c6sCbudoo1kT4CAJPh4kUkcgGVxW127WKw==} + peerDependencies: + svelte: ^5.7.0 + + runed@0.22.0: + resolution: {integrity: sha512-ZWVXWhOr0P5xdNgtviz6D1ivLUDWKLCbeC5SUEJ3zBkqLReVqWHenFxMNFeFaiC5bfxhFxyxzyzB+98uYFtwdA==} + peerDependencies: + svelte: ^5.7.0 + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -2126,6 +2154,12 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' + svelte-toolbelt@0.7.0: + resolution: {integrity: sha512-i/Tv4NwAWWqJnK5H0F8y/ubDnogDYlwwyzKhrspTUFzrFuGnYshqd2g4/R43ds841wmaFiSW/HsdsdWhPOlrAA==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.0.0 + svelte2tsx@0.7.31: resolution: {integrity: sha512-exrN1o9mdCLAA7hTCudz731FIxomH/0SN9ZIX+WrY/XnlLuno/NNC1PF6JXPZVqp/4sMMDKteqyKoG44hliljQ==} peerDependencies: @@ -2864,6 +2898,10 @@ snapshots: '@floating-ui/utils@0.2.8': {} + '@internationalized/date@3.7.0': + dependencies: + '@swc/helpers': 0.5.15 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3148,6 +3186,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.0.0-beta.9': dependencies: enhanced-resolve: 5.18.0 @@ -3406,6 +3448,16 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 + bits-ui@1.0.0-next.78(svelte@5.17.3): + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/dom': 1.6.12 + '@internationalized/date': 3.7.0 + esm-env: 1.2.1 + runed: 0.22.0(svelte@5.17.3) + svelte: 5.17.3 + svelte-toolbelt: 0.7.0(svelte@5.17.3) + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -4339,6 +4391,16 @@ snapshots: dependencies: queue-microtask: 1.2.3 + runed@0.20.0(svelte@5.17.3): + dependencies: + esm-env: 1.2.1 + svelte: 5.17.3 + + runed@0.22.0(svelte@5.17.3): + dependencies: + esm-env: 1.2.1 + svelte: 5.17.3 + sade@1.8.1: dependencies: mri: 1.2.0 @@ -4459,6 +4521,13 @@ snapshots: transitivePeerDependencies: - picomatch + svelte-toolbelt@0.7.0(svelte@5.17.3): + dependencies: + clsx: 2.1.1 + runed: 0.20.0(svelte@5.17.3) + style-to-object: 1.0.8 + svelte: 5.17.3 + svelte2tsx@0.7.31(svelte@5.17.3)(typescript@5.7.2): dependencies: dedent-js: 1.0.1 From 403a4ce39c8b0f61e91c5aa105374519aa58c914 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 21:11:50 -0500 Subject: [PATCH 48/87] more --- biome.json | 3 +- .../floating-focus-manager.svelte | 13 +++--- .../floating-portal/floating-portal.svelte | 41 ++++++++++--------- .../floating-portal/hooks.svelte.ts | 8 ++-- .../src/internal/handle-guard-focus.ts | 28 +++++++++++++ .../visual/components/drawer/drawer.svelte | 11 ++--- .../components/emoji-picker/main.svelte | 11 +++++ .../components/emoji-picker/option.svelte | 40 ++++++++++++++++++ .../test/visual/routes/drawer/+page.svelte | 5 +++ .../floating-ui-svelte/test/visual/types.ts | 6 +++ packages/floating-ui-svelte/vite.config.ts | 2 +- 11 files changed, 131 insertions(+), 37 deletions(-) create mode 100644 packages/floating-ui-svelte/src/internal/handle-guard-focus.ts create mode 100644 packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/drawer/+page.svelte create mode 100644 packages/floating-ui-svelte/test/visual/types.ts diff --git a/biome.json b/biome.json index cc6b0797..59c3c12f 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,8 @@ "rules": { "style": { "useConst": "off", - "useImportType": "off" + "useImportType": "off", + "noNonNullAssertion": "off" } } } diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index 6c20c811..cde5440b 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -156,6 +156,7 @@ import { box } from "../../internal/box.svelte.js"; import { reactiveActiveElement } from "../../internal/active-element.svelte.js"; import { sleep } from "../../internal/sleep.js"; + import { handleGuardFocus } from "../../internal/handle-guard-focus.js"; let { context, @@ -433,14 +434,14 @@ () => beforeGuardRef, () => { if (!portalContext) return; - portalContext.beforeInsideRef = beforeGuardRef; + portalContext.beforeInsideGuard = beforeGuardRef; } ); watch.pre( () => afterGuardRef, () => { if (!portalContext) return; - portalContext.afterInsideRef = afterGuardRef; + portalContext.afterInsideGuard = afterGuardRef; } ); @@ -482,8 +483,8 @@ endDismissButtonRef.current, beforeGuardRef, afterGuardRef, - portalContext?.beforeOutsideRef, - portalContext?.afterOutsideRef, + portalContext?.beforeOutsideGuard, + portalContext?.afterOutsideGuard, order.includes("reference") || isUntrappedTypeableCombobox ? context.domReference : null, @@ -811,7 +812,7 @@ nextTabbable?.focus(); }); } else { - portalContext.beforeOutsideRef?.focus(); + handleGuardFocus(portalContext.beforeOutsideGuard); } } }} /> @@ -850,7 +851,7 @@ will have a dismiss button. prevTabbable?.focus(); }); } else { - portalContext.afterOutsideRef?.focus(); + handleGuardFocus(portalContext.afterOutsideGuard); } } }} /> diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index 436759cf..d87e124e 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -42,6 +42,7 @@ diff --git a/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte b/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte new file mode 100644 index 00000000..ef279abb --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte @@ -0,0 +1,40 @@ + + + diff --git a/packages/floating-ui-svelte/test/visual/routes/drawer/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/drawer/+page.svelte new file mode 100644 index 00000000..645e302b --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/drawer/+page.svelte @@ -0,0 +1,5 @@ + + +
    diff --git a/packages/floating-ui-svelte/test/visual/types.ts b/packages/floating-ui-svelte/test/visual/types.ts new file mode 100644 index 00000000..94ade655 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/types.ts @@ -0,0 +1,6 @@ +import type { WritableBox } from "../../src/internal/box.svelte.js"; + +export type ReferenceSnippetProps = [ + WritableBox, + Record, +]; diff --git a/packages/floating-ui-svelte/vite.config.ts b/packages/floating-ui-svelte/vite.config.ts index 4c74106d..641212f6 100644 --- a/packages/floating-ui-svelte/vite.config.ts +++ b/packages/floating-ui-svelte/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ test: { root: "./test/unit", include: ["./**/*.test.ts"], - setupFiles: ["./setup.ts"], + setupFiles: ["./test/unit/setup.ts"], environment: "jsdom", }, }); From 85b76156dbf1f59a2b9b934720d3ab36f369b466 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 19 Jan 2025 23:38:45 -0500 Subject: [PATCH 49/87] wip list navigation tests --- biome.json | 3 + .../src/hooks/use-floating.svelte.ts | 4 +- .../src/hooks/use-interactions.svelte.ts | 6 +- .../src/hooks/use-list-navigation.svelte.ts | 1023 +++++++++++++++++ packages/floating-ui-svelte/src/index.ts | 1 + .../src/internal/composite.ts | 344 ++++++ .../unit/hooks/use-list-navigation.test.ts | 337 ++++++ .../use-list-navigation/autocomplete.svelte | 91 ++ .../use-list-navigation/main.svelte | 52 + 9 files changed, 1856 insertions(+), 5 deletions(-) create mode 100644 packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts create mode 100644 packages/floating-ui-svelte/src/internal/composite.ts create mode 100644 packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte diff --git a/biome.json b/biome.json index 59c3c12f..5f32d260 100644 --- a/biome.json +++ b/biome.json @@ -11,6 +11,9 @@ "useConst": "off", "useImportType": "off", "noNonNullAssertion": "off" + }, + "suspicious": { + "noAssignInExpressions": "off" } } } diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index e3afae62..05857a29 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -182,7 +182,7 @@ type FloatingContextOptions = { interface FloatingContextData { reference: ReferenceType | null; floating: HTMLElement | null; - domReference: NarrowedElement | null; + domReference: HTMLElement | null; x: number; y: number; placement: Placement; @@ -338,7 +338,7 @@ class FloatingState { }; get domReference() { - return this.#derivedDomReference; + return this.#derivedDomReference as HTMLElement | null; } get reference() { diff --git a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts index b63fd1b0..4470c6d9 100644 --- a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts @@ -11,10 +11,10 @@ interface ExtendedUserProps { interface ElementProps { reference?: HTMLAttributes; - floating?: HTMLAttributes; + floating?: HTMLAttributes; item?: - | HTMLAttributes - | ((props: ExtendedUserProps) => HTMLAttributes); + | HTMLAttributes + | ((props: ExtendedUserProps) => HTMLAttributes); } interface UseInteractionsReturn { diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts new file mode 100644 index 00000000..bb9b2d4d --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -0,0 +1,1023 @@ +import { DEV } from "esm-env"; +import { extract } from "../internal/extract.js"; +import type { + Boxed, + Dimensions, + FloatingTreeType, + MaybeGetter, +} from "../types.js"; +import type { FloatingRootContext } from "./use-floating-root-context.svelte.js"; +import type { FloatingContext } from "./use-floating.svelte.js"; +import { warn } from "../internal/log.js"; +import { getFloatingFocusElement } from "../internal/get-floating-focus-element.js"; +import { + useFloatingParentNodeId, + useFloatingTree, +} from "../components/floating-tree/hooks.svelte.js"; +import { isTypeableCombobox } from "../internal/is-typeable-element.js"; +import { enqueueFocus } from "../internal/enqueue-focus.js"; +import type { + FocusEventHandler, + KeyboardEventHandler, + MouseEventHandler, + PointerEventHandler, +} from "svelte/elements"; +import type { ElementProps } from "./use-interactions.svelte.js"; +import { watch } from "../internal/watch.svelte.js"; +import { + ARROW_DOWN, + ARROW_LEFT, + ARROW_RIGHT, + ARROW_UP, + buildCellMap, + findNonDisabledIndex, + getCellIndexOfCorner, + getCellIndices, + getGridNavigatedIndex, + getMaxIndex, + getMinIndex, + isDisabled, + isIndexOutOfBounds, +} from "../internal/composite.js"; +import { + activeElement, + contains, + getDocument, + isVirtualClick, + isVirtualPointerEvent, + stopEvent, +} from "../internal/dom.js"; +import { isElement, isHTMLElement } from "@floating-ui/utils/dom"; +import { getDeepestNode } from "../internal/get-children.js"; + +interface UseListNavigationOptions { + /** + * A ref that holds an array of list items. + * @default empty list + */ + listRef: MaybeGetter>; + /** + * The index of the currently active (focused or highlighted) item, which may + * or may not be selected. + * @default null + */ + activeIndex: MaybeGetter; + /** + * A callback that is called when the user navigates to a new active item, + * passed in a new `activeIndex`. + */ + onNavigate?: (activeIndex: number | null) => void; + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: MaybeGetter; + /** + * The currently selected item index, which may or may not be active. + * @default null + */ + selectedIndex?: MaybeGetter; + /** + * Whether to focus the item upon opening the floating element. 'auto' infers + * what to do based on the input type (keyboard vs. pointer), while a boolean + * value will force the value. + * @default 'auto' + */ + focusItemOnOpen?: MaybeGetter; + /** + * Whether hovering an item synchronizes the focus. + * @default true + */ + focusItemOnHover?: MaybeGetter; + /** + * Whether pressing an arrow key on the navigation’s main axis opens the + * floating element. + * @default true + */ + openOnArrowKeyDown?: MaybeGetter; + /** + * By default elements with either a `disabled` or `aria-disabled` attribute + * are skipped in the list navigation — however, this requires the items to + * be rendered. + * This prop allows you to manually specify indices which should be disabled, + * overriding the default logic. + * For Windows-style select menus, where the menu does not open when + * navigating via arrow keys, specify an empty array. + * @default undefined + */ + disabledIndices?: MaybeGetter>; + /** + * Determines whether focus can escape the list, such that nothing is selected + * after navigating beyond the boundary of the list. In some + * autocomplete/combobox components, this may be desired, as screen + * readers will return to the input. + * `loop` must be `true`. + * @default false + */ + allowEscape?: MaybeGetter; + /** + * Determines whether focus should loop around when navigating past the first + * or last item. + * @default false + */ + loop?: MaybeGetter; + /** + * If the list is nested within another one (e.g. a nested submenu), the + * navigation semantics change. + * @default false + */ + nested?: MaybeGetter; + /** + * Whether the direction of the floating element’s navigation is in RTL + * layout. + * @default false + */ + rtl?: MaybeGetter; + /** + * Whether the focus is virtual (using `aria-activedescendant`). + * Use this if you need focus to remain on the reference element + * (such as an input), but allow arrow keys to navigate list items. + * This is common in autocomplete listbox components. + * Your virtually-focused list items must have a unique `id` set on them. + * If you’re using a component role with the `useRole()` Hook, then an `id` is + * generated automatically. + * @default false + */ + virtual?: MaybeGetter; + /** + * The orientation in which navigation occurs. + * @default 'vertical' + */ + orientation?: MaybeGetter<"vertical" | "horizontal" | "both">; + /** + * Specifies how many columns the list has (i.e., it’s a grid). Use an + * orientation of 'horizontal' (e.g. for an emoji picker/date picker, where + * pressing ArrowRight or ArrowLeft can change rows), or 'both' (where the + * current row cannot be escaped with ArrowRight or ArrowLeft, only ArrowUp + * and ArrowDown). + * @default 1 + */ + cols?: MaybeGetter; + /** + * Whether to scroll the active item into view when navigating. The default + * value uses nearest options. + */ + scrollItemIntoView?: MaybeGetter; + /** + * When using virtual focus management, this holds a ref to the + * virtually-focused item. This allows nested virtual navigation to be + * enabled, and lets you know when a nested element is virtually focused from + * the root reference handling the events. Requires `FloatingTree` to be + * setup. + */ + virtualItemRef?: Boxed; + /** + * Only for `cols > 1`, specify sizes for grid items. + * `{ width: 2, height: 2 }` means an item is 2 columns wide and 2 rows tall. + */ + itemSizes?: MaybeGetter; + /** + * Only relevant for `cols > 1` and items with different sizes, specify if + * the grid is dense (as defined in the CSS spec for `grid-auto-flow`). + * @default false + */ + dense?: MaybeGetter; +} + +class ListNavigationState { + #listRef = $derived.by(() => extract(this.opts?.listRef)); + #activeIndex = $derived.by(() => extract(this.opts?.activeIndex)); + #enabled = $derived.by(() => extract(this.opts?.enabled, true)); + #selectedIndex = $derived.by(() => extract(this.opts?.selectedIndex, null)); + #allowEscape = $derived.by(() => extract(this.opts?.allowEscape, false)); + #loop = $derived.by(() => extract(this.opts?.loop, false)); + #nested = $derived.by(() => extract(this.opts?.nested, false)); + #rtl = $derived.by(() => extract(this.opts?.rtl, false)); + #virtual = $derived.by(() => extract(this.opts?.virtual, false)); + #focusItemOnOpenProp = $derived.by(() => + extract(this.opts?.focusItemOnOpen, "auto"), + ); + #focusItemOnHover = $derived.by(() => + extract(this.opts?.focusItemOnHover, true), + ); + #openOnArrowKeyDown = $derived.by(() => + extract(this.opts?.openOnArrowKeyDown, true), + ); + #disabledIndices = $derived.by(() => + extract(this.opts?.disabledIndices, undefined), + ); + #orientation = $derived.by(() => extract(this.opts?.orientation, "vertical")); + #cols = $derived.by(() => extract(this.opts?.cols, 1)); + #scrollItemIntoView = $derived.by(() => + extract(this.opts?.scrollItemIntoView, true), + ); + #virtualItemRef: UseListNavigationOptions["virtualItemRef"]; + #itemSizes = $derived.by(() => extract(this.opts?.itemSizes)); + #dense = $derived.by(() => extract(this.opts?.dense, false)); + #floatingFocusElement = $derived.by(() => + getFloatingFocusElement(this.context.floating), + ); + #parentId: string | null = null; + #tree: FloatingTreeType | null = null; + #index = $state(this.#selectedIndex ?? -1); + #typeableComboboxReference = $derived.by(() => + isTypeableCombobox(this.context.domReference), + ); + + #key: string | null = null; + #isPointerModality = true; + #forceSyncFocus = false; + #forceScrollIntoView = false; + + #activeId = $state(); + #virtualId = $state(); + #previousMounted: boolean; + #previousOpen: boolean; + #previousOnNavigate: () => void; + #hasActiveIndex = $derived.by(() => this.#activeIndex != null); + + #ariaActiveDescendantProp = $derived.by(() => { + if (this.#virtual && this.context.open && this.#hasActiveIndex) { + return { + "aria-activedescendant": this.#virtualId || this.#activeId, + }; + } + return {}; + }); + + #focusItemOnOpen = $state(this.#focusItemOnOpenProp); + + constructor( + private readonly context: FloatingContext | FloatingRootContext, + private readonly opts: UseListNavigationOptions, + ) { + if (DEV) { + if (this.#allowEscape) { + if (!this.#loop) { + warn("`useListNavigation` looping must be enabled to allow escaping"); + } + + if (!this.#virtual) { + warn("`useListNavigation` must be virtual to allow escaping"); + } + } + + if (this.#orientation === "vertical" && this.#cols > 1) { + warn( + "In grid list navigation mode (`cols` > 1), the `orientation` should", + 'be either "horizontal" or "both".', + ); + } + } + this.#virtualItemRef = this.opts.virtualItemRef; + this.#parentId = useFloatingParentNodeId(); + this.#tree = useFloatingTree(); + this.#previousMounted = !!this.context.floating; + this.#previousOpen = this.context.open; + this.#previousOnNavigate = this.#onNavigate; + + // Sync `selectedIndex` to be the `activeIndex` upon opening the floating + // element. Also, reset `activeIndex` upon closing the floating element. + watch.pre( + [ + () => this.#enabled, + () => this.context.open, + () => this.context.floating, + () => this.#selectedIndex, + ], + () => { + if (!this.#enabled) return; + if (this.context.open && this.context.floating) { + if (this.#focusItemOnOpen && this.#selectedIndex != null) { + // Regardless of the pointer modality, we want to ensure the selected + // item comes into view when the floating element is opened. + this.#forceScrollIntoView = true; + this.#index = this.#selectedIndex; + this.#onNavigate(); + } + } else if (this.#previousMounted) { + this.#index = -1; + this.#onNavigate(); + } + }, + ); + + // Sync `activeIndex` to be the focused item while the floating element is + // open. + watch.pre( + [ + () => this.#enabled, + () => this.context.open, + () => this.context.floating, + () => this.#activeIndex, + () => this.#selectedIndex, + () => this.#nested, + () => this.#listRef, + () => this.#orientation, + () => this.#rtl, + () => this.#disabledIndices, + ], + () => { + if (!this.#enabled) return; + if (!this.context.open) return; + if (!this.context.floating) return; + + if (this.#activeIndex == null) { + this.#forceSyncFocus = false; + + if (this.#selectedIndex != null) { + return; + } + + // Reset while the floating element was open (e.g. the list changed). + if (this.#previousMounted) { + this.#index = -1; + this.#focusItem(); + } + + // Initial sync. + if ( + (!this.#previousOpen || !this.#previousMounted) && + this.#focusItemOnOpen && + (this.#key != null || + (this.#focusItemOnOpen === true && this.#key == null)) + ) { + let runs = 0; + const waitForListPopulated = () => { + if (this.#listRef[0] == null) { + // Avoid letting the browser paint if possible on the first try, + // otherwise use rAF. Don't try more than twice, since something + // is wrong otherwise. + if (runs < 2) { + const scheduler = runs + ? requestAnimationFrame + : queueMicrotask; + scheduler(waitForListPopulated); + } + runs++; + } else { + this.#index = + this.#key == null || + isMainOrientationToEndKey( + this.#key, + this.#orientation, + this.#rtl, + ) || + this.#nested + ? getMinIndex(this.#listRef, this.#disabledIndices) + : getMaxIndex(this.#listRef, this.#disabledIndices); + this.#key = null; + this.#onNavigate(); + } + }; + + waitForListPopulated(); + } + } else if (!isIndexOutOfBounds(this.#listRef, this.#activeIndex)) { + this.#index = this.#activeIndex; + this.#focusItem(); + this.#forceScrollIntoView = false; + } + }, + ); + + // Ensure the parent floating element has focus when a nested child closes + // to allow arrow key navigation to work after the pointer leaves the child. + watch.pre( + [ + () => this.#enabled, + () => this.context.floating, + () => this.#tree?.nodes, + () => this.#virtual, + ], + () => { + if ( + !this.#enabled || + this.context.floating || + !this.#tree || + this.#virtual || + !this.#previousMounted + ) { + return; + } + + const nodes = this.#tree.nodes; + const parent = nodes.find((node) => node.id === this.#parentId)?.context + ?.floating; + const activeEl = activeElement(getDocument(this.context.floating)); + const treeContainsActiveEl = nodes.some( + (node) => node.context && contains(node.context.floating, activeEl), + ); + + if (parent && !treeContainsActiveEl && this.#isPointerModality) { + parent.focus({ preventScroll: true }); + } + }, + ); + + watch.pre( + [ + () => this.#enabled, + () => this.#tree?.nodes, + () => this.#virtual, + () => this.#virtualItemRef?.current, + ], + () => { + if (!this.#enabled) return; + if (!this.#tree) return; + if (!this.#virtual) return; + if (this.#parentId) return; + + const handleVirtualFocus = (item: HTMLElement) => { + this.#virtualId = item.id; + if (this.#virtualItemRef) { + this.#virtualItemRef.current = item; + } + }; + const tree = this.#tree; + + tree.events.on("virtualfocus", handleVirtualFocus); + + return () => { + tree.events.off("virtualfocus", handleVirtualFocus); + }; + }, + ); + + $effect.pre(() => { + this.#previousMounted = !!this.context.floating; + }); + + $effect.pre(() => { + this.#previousOpen = this.context.open; + }); + + $effect.pre(() => { + this.#previousOnNavigate = this.#onNavigate; + }); + + $effect.pre(() => { + this.#focusItemOnOpen = this.#focusItemOnOpenProp; + }); + + watch.pre( + () => this.context.open, + () => { + if (!this.context.open) { + this.#key = null; + } + }, + ); + } + + #onNavigate = () => { + this.opts.onNavigate?.(this.#index === -1 ? null : this.#index); + }; + + #syncCurrentTarget = (currentTarget: HTMLElement | null) => { + if (!this.context.open) return; + const index = this.#listRef.indexOf(currentTarget); + if (index !== -1 && this.#index !== index) { + this.#index = index; + this.#onNavigate(); + } + }; + + #itemOnFocus: FocusEventHandler = ({ currentTarget }) => { + this.#forceSyncFocus = true; + this.#syncCurrentTarget(currentTarget); + }; + + #itemOnClick: MouseEventHandler = ({ currentTarget }) => + currentTarget.focus({ preventScroll: true }); // safari + + #itemOnMouseMove: MouseEventHandler = ({ currentTarget }) => { + this.#forceSyncFocus = true; + this.#forceScrollIntoView = false; + this.#syncCurrentTarget(currentTarget); + }; + + #itemOnPointerLeave: PointerEventHandler = ({ pointerType }) => { + if (!this.#isPointerModality || pointerType === "touch") return; + + this.#forceSyncFocus = true; + this.#index = -1; + this.#onNavigate(); + + if (!this.#virtual) { + this.#floatingFocusElement?.focus({ preventScroll: true }); + } + }; + + #item: ElementProps["item"] = $derived.by(() => ({ + onfocus: this.#itemOnFocus, + onclick: this.#itemOnClick, + ...(this.#focusItemOnHover && { + onmousemove: this.#itemOnMouseMove, + onpointerleave: this.#itemOnPointerLeave, + }), + })); + + #focusItem() { + const runFocus = (item: HTMLElement) => { + if (this.#virtual) { + this.#activeId = item.id; + this.#tree?.events.emit("virtualfocus", item); + if (this.#virtualItemRef) { + this.#virtualItemRef.current = item; + } + } else { + enqueueFocus(item, { + sync: this.#forceSyncFocus, + preventScroll: true, + }); + } + }; + + const initialItem = this.#listRef[this.#index]; + + if (initialItem) { + runFocus(initialItem); + } + + const scheduler = this.#forceSyncFocus + ? (v: () => void) => v() + : requestAnimationFrame; + + scheduler(() => { + const waitedItem = this.#listRef[this.#index] || initialItem; + + if (!waitedItem) return; + + if (!initialItem) { + runFocus(waitedItem); + } + + const scrollIntoViewOptions = this.#scrollItemIntoView; + const shouldScrollIntoView = + scrollIntoViewOptions && + this.#item && + (this.#forceScrollIntoView || !this.#isPointerModality); + + if (shouldScrollIntoView) { + // JSDOM doesn't support `.scrollIntoView()` but it's widely supported + // by all browsers. + waitedItem.scrollIntoView?.( + typeof scrollIntoViewOptions === "boolean" + ? { block: "nearest", inline: "nearest" } + : scrollIntoViewOptions, + ); + } + }); + } + + #commonOnKeyDown = (event: KeyboardEvent) => { + this.#isPointerModality = true; + this.#forceSyncFocus = true; + + // When composing a character, Chrome fires ArrowDown twice. Firefox/Safari + // don't appear to suffer from this. `event.isComposing` is avoided due to + // Safari not supporting it properly (although it's not needed in the first + // place for Safari, just avoiding any possible issues). + if (event.which === 229) return; + + // If the floating element is animating out, ignore navigation. Otherwise, + // the `activeIndex` gets set to 0 despite not being open so the next time + // the user ArrowDowns, the first item won't be focused. + if ( + !this.context.open && + event.currentTarget === this.#floatingFocusElement + ) { + return; + } + + if ( + this.#nested && + isCrossOrientationCloseKey(event.key, this.#orientation, this.#rtl) + ) { + stopEvent(event); + this.context.onOpenChange(false, event, "list-navigation"); + + if (isHTMLElement(this.context.domReference)) { + if (this.#virtual) { + this.#tree?.events.emit("virtualfocus", this.context.domReference); + } else { + this.context.domReference.focus(); + } + } + + return; + } + + const currentIndex = this.#index; + const minIndex = getMinIndex(this.#listRef, this.#disabledIndices); + const maxIndex = getMaxIndex(this.#listRef, this.#disabledIndices); + + if (!this.#typeableComboboxReference) { + if (event.key === "Home") { + stopEvent(event); + this.#index = minIndex; + this.#onNavigate(); + } + + if (event.key === "End") { + stopEvent(event); + this.#index = maxIndex; + this.#onNavigate(); + } + } + + // Grid navigation. + if (this.#cols > 1) { + const sizes = + this.#itemSizes || + Array.from({ length: this.#listRef.length }, () => ({ + width: 1, + height: 1, + })); + // To calculate movements on the grid, we use hypothetical cell indices + // as if every item was 1x1, then convert back to real indices. + const cellMap = buildCellMap(sizes, this.#cols, this.#dense); + const minGridIndex = cellMap.findIndex( + (index) => + index != null && + !isDisabled(this.#listRef, index, this.#disabledIndices), + ); + // last enabled index + const maxGridIndex = cellMap.reduce( + (foundIndex: number, index, cellIndex) => + index != null && + !isDisabled(this.#listRef, index, this.#disabledIndices) + ? cellIndex + : foundIndex, + -1, + ); + + const index = + cellMap[ + getGridNavigatedIndex( + cellMap.map((itemIndex) => + itemIndex != null ? this.#listRef[itemIndex] : null, + ), + { + event, + orientation: this.#orientation, + loop: this.#loop, + rtl: this.#rtl, + cols: this.#cols, + // treat undefined (empty grid spaces) as disabled indices so we + // don't end up in them + disabledIndices: getCellIndices( + [ + ...(this.#disabledIndices || + this.#listRef.map((_, index) => + isDisabled(this.#listRef, index) ? index : undefined, + )), + undefined, + ], + cellMap, + ), + minIndex: minGridIndex, + maxIndex: maxGridIndex, + prevIndex: getCellIndexOfCorner( + this.#index > maxIndex ? minIndex : this.#index, + sizes, + cellMap, + this.#cols, + // use a corner matching the edge closest to the direction + // we're moving in so we don't end up in the same item. Prefer + // top/left over bottom/right. + event.key === ARROW_DOWN + ? "bl" + : event.key === (this.#rtl ? ARROW_LEFT : ARROW_RIGHT) + ? "tr" + : "tl", + ), + stopEvent: true, + }, + ) + ]; + + if (index != null) { + this.#index = index; + this.#onNavigate(); + } + + if (this.#orientation === "both") { + return; + } + } + + if (isMainOrientationKey(event.key, this.#orientation)) { + stopEvent(event); + + // Reset the index if no item is focused. + if ( + this.context.open && + !this.#virtual && + isElement(event.currentTarget) && + activeElement(event.currentTarget.ownerDocument) === event.currentTarget + ) { + this.#index = isMainOrientationToEndKey( + event.key, + this.#orientation, + this.#rtl, + ) + ? minIndex + : maxIndex; + this.#onNavigate(); + return; + } + + if (isMainOrientationToEndKey(event.key, this.#orientation, this.#rtl)) { + if (this.#loop) { + this.#index = + currentIndex >= maxIndex + ? this.#allowEscape && currentIndex !== this.#listRef.length + ? -1 + : minIndex + : findNonDisabledIndex(this.#listRef, { + startingIndex: currentIndex, + disabledIndices: this.#disabledIndices, + }); + } else { + this.#index = Math.min( + maxIndex, + findNonDisabledIndex(this.#listRef, { + startingIndex: currentIndex, + disabledIndices: this.#disabledIndices, + }), + ); + } + } else { + if (this.#loop) { + this.#index = + currentIndex <= minIndex + ? this.#allowEscape && currentIndex !== -1 + ? this.#listRef.length + : maxIndex + : findNonDisabledIndex(this.#listRef, { + startingIndex: currentIndex, + decrement: true, + disabledIndices: this.#disabledIndices, + }); + } else { + this.#index = Math.max( + minIndex, + findNonDisabledIndex(this.#listRef, { + startingIndex: currentIndex, + decrement: true, + disabledIndices: this.#disabledIndices, + }), + ); + } + } + + if (isIndexOutOfBounds(this.#listRef, this.#index)) { + this.#index = -1; + } + + this.#onNavigate(); + } + }; + + #floatingOnPointerMove: PointerEventHandler = () => { + this.#isPointerModality = true; + }; + + #floating: ElementProps["floating"] = $derived.by(() => ({ + "aria-orientation": + this.#orientation === "both" ? undefined : this.#orientation, + ...(!this.#typeableComboboxReference ? this.#ariaActiveDescendantProp : {}), + onkeydown: this.#commonOnKeyDown, + onpointermove: this.#floatingOnPointerMove, + })); + + #checkVirtualMouse = (event: MouseEvent) => { + if (this.#focusItemOnOpenProp === "auto" && isVirtualClick(event)) { + this.#focusItemOnOpen = true; + } + }; + + #checkVirtualPointer = (event: PointerEvent) => { + // `pointerdown` fires first, reset the state then perform the checks. + this.#focusItemOnOpen = this.#focusItemOnOpenProp; + if (this.#focusItemOnOpenProp === "auto" && isVirtualPointerEvent(event)) { + this.#focusItemOnOpen = true; + } + }; + + #referenceOnKeyDown = (event: KeyboardEvent) => { + this.#isPointerModality = false; + + const isArrowKey = event.key.startsWith("Arrow"); + const isHomeOrEndKey = ["Home", "End"].includes(event.key); + const isMoveKey = isArrowKey || isHomeOrEndKey; + const isCrossOpenKey = isCrossOrientationOpenKey( + event.key, + this.#orientation, + this.#rtl, + ); + const isCrossCloseKey = isCrossOrientationCloseKey( + event.key, + this.#orientation, + this.#rtl, + ); + const isMainKey = isMainOrientationKey(event.key, this.#orientation); + const isNavigationKey = + (this.#nested ? isCrossOpenKey : isMainKey) || + event.key === "Enter" || + event.key.trim() === ""; + + if (this.#virtual && this.context.open) { + const rootNode = this.#tree?.nodes.find((node) => node.parentId == null); + const deepestNode = + this.#tree && rootNode + ? getDeepestNode(this.#tree.nodes, rootNode.id) + : null; + + if (isMoveKey && deepestNode && this.#virtualItemRef) { + const eventObject = new KeyboardEvent("keydown", { + key: event.key, + bubbles: true, + }); + + if (isCrossOpenKey || isCrossCloseKey) { + const isCurrentTarget = + deepestNode.context?.domReference === event.currentTarget; + const dispatchItem = + isCrossCloseKey && !isCurrentTarget + ? deepestNode.context?.domReference + : isCrossOpenKey + ? this.#listRef.find((item) => item?.id === this.#activeId) + : null; + + if (dispatchItem) { + stopEvent(event); + dispatchItem.dispatchEvent(eventObject); + this.#virtualId = undefined; + } + } + + if ((isMainKey || isHomeOrEndKey) && deepestNode.context) { + if ( + deepestNode.context.open && + deepestNode.parentId && + event.currentTarget !== deepestNode.context.domReference + ) { + stopEvent(event); + deepestNode.context.domReference?.dispatchEvent(eventObject); + return; + } + } + } + + return this.#commonOnKeyDown(event); + } + + // If a floating element should not open on arrow key down, avoid + // setting `activeIndex` while it's closed. + if (!this.context.open && !this.#openOnArrowKeyDown && isArrowKey) { + return; + } + + if (isNavigationKey) { + this.#key = this.#nested && isMainKey ? null : event.key; + } + + if (this.#nested) { + if (isCrossOpenKey) { + stopEvent(event); + + if (this.context.open) { + this.#index = getMinIndex(this.#listRef, this.#disabledIndices); + this.#onNavigate(); + } else { + this.context.onOpenChange(true, event, "list-navigation"); + } + } + + return; + } + + if (isMainKey) { + if (this.#selectedIndex != null) { + this.#index = this.#selectedIndex; + } + + stopEvent(event); + + if (!this.context.open && this.#openOnArrowKeyDown) { + this.context.onOpenChange(true, event, "list-navigation"); + } else { + this.#commonOnKeyDown(event); + } + + if (this.context.open) { + this.#onNavigate(); + } + } + }; + + #referenceOnFocus = () => { + if (this.context.open && !this.#virtual) { + this.#index = -1; + this.#onNavigate(); + } + }; + + #reference: ElementProps["reference"] = $derived.by(() => ({ + ...this.#ariaActiveDescendantProp, + onkeydown: this.#referenceOnKeyDown, + onfocus: this.#referenceOnFocus, + onpointerdown: this.#checkVirtualPointer, + onpointerenter: this.#checkVirtualPointer, + onmousedown: this.#checkVirtualMouse, + onclick: this.#checkVirtualMouse, + })); + + get floating() { + if (!this.#enabled) return {}; + return this.#floating; + } + + get item() { + if (!this.#enabled) return {}; + return this.#item; + } + + get reference() { + if (!this.#enabled) return {}; + return this.#reference; + } +} + +function doSwitch( + orientation: UseListNavigationOptions["orientation"], + vertical: boolean, + horizontal: boolean, +) { + switch (orientation) { + case "vertical": + return vertical; + case "horizontal": + return horizontal; + default: + return vertical || horizontal; + } +} + +function isMainOrientationKey( + key: string, + orientation: UseListNavigationOptions["orientation"], +) { + const vertical = key === ARROW_UP || key === ARROW_DOWN; + const horizontal = key === ARROW_LEFT || key === ARROW_RIGHT; + return doSwitch(orientation, vertical, horizontal); +} + +function isMainOrientationToEndKey( + key: string, + orientation: UseListNavigationOptions["orientation"], + rtl: boolean, +) { + const vertical = key === ARROW_DOWN; + const horizontal = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; + return ( + doSwitch(orientation, vertical, horizontal) || + key === "Enter" || + key === " " || + key === "" + ); +} + +function isCrossOrientationOpenKey( + key: string, + orientation: UseListNavigationOptions["orientation"], + rtl: boolean, +) { + const vertical = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; + const horizontal = key === ARROW_DOWN; + return doSwitch(orientation, vertical, horizontal); +} + +function isCrossOrientationCloseKey( + key: string, + orientation: UseListNavigationOptions["orientation"], + rtl: boolean, +) { + const vertical = rtl ? key === ARROW_RIGHT : key === ARROW_LEFT; + const horizontal = key === ARROW_UP; + return doSwitch(orientation, vertical, horizontal); +} + +function useListNavigation( + context: FloatingContext | FloatingRootContext, + opts: UseListNavigationOptions, +) { + return new ListNavigationState(context, opts); +} + +export { useListNavigation }; +export type { UseListNavigationOptions }; diff --git a/packages/floating-ui-svelte/src/index.ts b/packages/floating-ui-svelte/src/index.ts index e8984bf6..6f26d91e 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -36,6 +36,7 @@ export * from "./hooks/use-id.js"; export * from "./hooks/use-interactions.svelte.js"; export * from "./hooks/use-role.svelte.js"; export * from "./hooks/use-transition.svelte.js"; +export * from "./hooks/use-list-navigation.svelte.js"; export * from "./safe-polygon.js"; /** diff --git a/packages/floating-ui-svelte/src/internal/composite.ts b/packages/floating-ui-svelte/src/internal/composite.ts new file mode 100644 index 00000000..2eee7cc3 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/composite.ts @@ -0,0 +1,344 @@ +import { floor } from "@floating-ui/utils"; +import { stopEvent } from "./dom.js"; +import type { Dimensions } from "../types.js"; +import { DEV } from "esm-env"; + +const ARROW_UP = "ArrowUp"; +const ARROW_DOWN = "ArrowDown"; +const ARROW_LEFT = "ArrowLeft"; +const ARROW_RIGHT = "ArrowRight"; + +function isDifferentRow(index: number, cols: number, prevRow: number) { + return Math.floor(index / cols) !== prevRow; +} + +function isIndexOutOfBounds(listRef: Array, index: number) { + return index < 0 || index >= listRef.length; +} + +function getMinIndex( + listRef: Array, + disabledIndices: Array | undefined, +) { + return findNonDisabledIndex(listRef, { disabledIndices }); +} + +function getMaxIndex( + listRef: Array, + disabledIndices: Array | undefined, +) { + return findNonDisabledIndex(listRef, { + decrement: true, + startingIndex: listRef.length, + disabledIndices, + }); +} + +function findNonDisabledIndex( + listRef: Array, + { + startingIndex = -1, + decrement = false, + disabledIndices, + amount = 1, + }: { + startingIndex?: number; + decrement?: boolean; + disabledIndices?: Array; + amount?: number; + } = {}, +): number { + const list = listRef; + + let index = startingIndex; + do { + index += decrement ? -amount : amount; + } while ( + index >= 0 && + index <= list.length - 1 && + isDisabled(list, index, disabledIndices) + ); + + return index; +} + +function getGridNavigatedIndex( + elementsRef: Array, + { + event, + orientation, + loop, + rtl, + cols, + disabledIndices, + minIndex, + maxIndex, + prevIndex, + stopEvent: stop = false, + }: { + event: KeyboardEvent; + orientation: "horizontal" | "vertical" | "both"; + loop: boolean; + rtl: boolean; + cols: number; + disabledIndices: Array | undefined; + minIndex: number; + maxIndex: number; + prevIndex: number; + stopEvent?: boolean; + }, +) { + let nextIndex = prevIndex; + + if (event.key === ARROW_UP) { + stop && stopEvent(event); + + if (prevIndex === -1) { + nextIndex = maxIndex; + } else { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: nextIndex, + amount: cols, + decrement: true, + disabledIndices, + }); + + if (loop && (prevIndex - cols < minIndex || nextIndex < 0)) { + const col = prevIndex % cols; + const maxCol = maxIndex % cols; + const offset = maxIndex - (maxCol - col); + + if (maxCol === col) { + nextIndex = maxIndex; + } else { + nextIndex = maxCol > col ? offset : offset - cols; + } + } + } + + if (isIndexOutOfBounds(elementsRef, nextIndex)) { + nextIndex = prevIndex; + } + } + + if (event.key === ARROW_DOWN) { + stop && stopEvent(event); + + if (prevIndex === -1) { + nextIndex = minIndex; + } else { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: prevIndex, + amount: cols, + disabledIndices, + }); + + if (loop && prevIndex + cols > maxIndex) { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: (prevIndex % cols) - cols, + amount: cols, + disabledIndices, + }); + } + } + + if (isIndexOutOfBounds(elementsRef, nextIndex)) { + nextIndex = prevIndex; + } + } + + // Remains on the same row/column. + if (orientation === "both") { + const prevRow = floor(prevIndex / cols); + + if (event.key === (rtl ? ARROW_LEFT : ARROW_RIGHT)) { + stop && stopEvent(event); + + if (prevIndex % cols !== cols - 1) { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: prevIndex, + disabledIndices, + }); + + if (loop && isDifferentRow(nextIndex, cols, prevRow)) { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: prevIndex - (prevIndex % cols) - 1, + disabledIndices, + }); + } + } else if (loop) { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: prevIndex - (prevIndex % cols) - 1, + disabledIndices, + }); + } + + if (isDifferentRow(nextIndex, cols, prevRow)) { + nextIndex = prevIndex; + } + } + + if (event.key === (rtl ? ARROW_RIGHT : ARROW_LEFT)) { + stop && stopEvent(event); + + if (prevIndex % cols !== 0) { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: prevIndex, + decrement: true, + disabledIndices, + }); + + if (loop && isDifferentRow(nextIndex, cols, prevRow)) { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: prevIndex + (cols - (prevIndex % cols)), + decrement: true, + disabledIndices, + }); + } + } else if (loop) { + nextIndex = findNonDisabledIndex(elementsRef, { + startingIndex: prevIndex + (cols - (prevIndex % cols)), + decrement: true, + disabledIndices, + }); + } + + if (isDifferentRow(nextIndex, cols, prevRow)) { + nextIndex = prevIndex; + } + } + + const lastRow = floor(maxIndex / cols) === prevRow; + + if (isIndexOutOfBounds(elementsRef, nextIndex)) { + if (loop && lastRow) { + nextIndex = + event.key === (rtl ? ARROW_RIGHT : ARROW_LEFT) + ? maxIndex + : findNonDisabledIndex(elementsRef, { + startingIndex: prevIndex - (prevIndex % cols) - 1, + disabledIndices, + }); + } else { + nextIndex = prevIndex; + } + } + } + + return nextIndex; +} + +/** For each cell index, gets the item index that occupies that cell */ +function buildCellMap(sizes: Dimensions[], cols: number, dense: boolean) { + const cellMap: (number | undefined)[] = []; + let startIndex = 0; + sizes.forEach(({ width, height }, index) => { + if (width > cols) { + if (DEV) { + throw new Error( + `[Floating UI]: Invalid grid - item width at index ${index} is greater than grid columns`, + ); + } + } + let itemPlaced = false; + if (dense) { + startIndex = 0; + } + while (!itemPlaced) { + const targetCells: number[] = []; + for (let i = 0; i < width; i++) { + for (let j = 0; j < height; j++) { + targetCells.push(startIndex + i + j * cols); + } + } + if ( + (startIndex % cols) + width <= cols && + targetCells.every((cell) => cellMap[cell] == null) + ) { + for (const cell of targetCells) { + cellMap[cell] = index; + } + itemPlaced = true; + } else { + startIndex++; + } + } + }); + + // convert into a non-sparse array + return [...cellMap]; +} + +/** Gets cell index of an item's corner or -1 when index is -1. */ +function getCellIndexOfCorner( + index: number, + sizes: Dimensions[], + cellMap: (number | undefined)[], + cols: number, + corner: "tl" | "tr" | "bl" | "br", +) { + if (index === -1) return -1; + + const firstCellIndex = cellMap.indexOf(index); + const sizeItem = sizes[index]; + + switch (corner) { + case "tl": + return firstCellIndex; + case "tr": + if (!sizeItem) { + return firstCellIndex; + } + return firstCellIndex + sizeItem.width - 1; + case "bl": + if (!sizeItem) { + return firstCellIndex; + } + return firstCellIndex + (sizeItem.height - 1) * cols; + case "br": + return cellMap.lastIndexOf(index); + } +} + +/** Gets all cell indices that correspond to the specified indices */ +function getCellIndices( + indices: (number | undefined)[], + cellMap: (number | undefined)[], +) { + return cellMap.flatMap((index, cellIndex) => + indices.includes(index) ? [cellIndex] : [], + ); +} + +function isDisabled( + list: Array, + index: number, + disabledIndices?: Array, +) { + if (disabledIndices) { + return disabledIndices.includes(index); + } + + const element = list[index]; + return ( + element == null || + element.hasAttribute("disabled") || + element.getAttribute("aria-disabled") === "true" + ); +} + +export { + getCellIndexOfCorner, + getCellIndices, + ARROW_DOWN, + ARROW_LEFT, + ARROW_RIGHT, + ARROW_UP, + buildCellMap, + getGridNavigatedIndex, + getMinIndex, + getMaxIndex, + isIndexOutOfBounds, + isDisabled, + findNonDisabledIndex, +}; diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts new file mode 100644 index 00000000..4bd6bb2f --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts @@ -0,0 +1,337 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import Main from "./wrapper-components/use-list-navigation/main.svelte"; +import Autocomplete from "./wrapper-components/use-list-navigation/autocomplete.svelte"; +import { userEvent } from "@testing-library/user-event"; +import { testKbd } from "../../utils.js"; + +it("opens on ArrowDown and focuses first item", async () => { + render(Main); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + + await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); +}); + +it("opens on ArrowUp and focuses last item", async () => { + render(Main); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowUp" }); + + await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); +}); + +it("navigates down on ArrowDown", async () => { + render(Main); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + + // Reached the end of the list. + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); +}); + +it("navigates up on ArrowUp", async () => { + render(Main); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + // Reached the end of the list. + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); +}); + +it("resets index to -1 upon close", async () => { + render(Autocomplete); + screen.getByTestId("reference").focus(); + + await userEvent.keyboard("a"); + + expect(screen.getByTestId("floating")).toBeInTheDocument(); + expect(screen.getByTestId("active-index").textContent).toBe(""); + + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); + + expect(screen.getByTestId("active-index").textContent).toBe("2"); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.getByTestId("active-index").textContent).toBe(""); + + await userEvent.keyboard(testKbd.BACKSPACE); + await userEvent.keyboard("a"); + + expect(screen.getByTestId("floating")).toBeInTheDocument(); + expect(screen.getByTestId("active-index").textContent).toBe(""); + + await userEvent.keyboard(testKbd.ARROW_DOWN); + + expect(screen.getByTestId("active-index").textContent).toBe("0"); +}); + +describe("loop", () => { + it("handles ArrowDown looping", async () => { + render(Main, { loop: true }); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + + // Reached the end of the list and loops. + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + }); + + it("handles ArrowDown looping", async () => { + render(Main, { loop: true }); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + // Reached the end of the list and loops. + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + }); +}); + +describe("orientation", () => { + it("navigates down on ArrowRight", async () => { + render(Main, { orientation: "horizontal" }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowRight" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowRight" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowRight" }); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + + // Reached the end of the list. + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowRight" }); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + }); + + it("navigates up on ArrowLeft", async () => { + render(Main, { orientation: "horizontal" }); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowLeft" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowLeft" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowLeft" }); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + // Reached the end of the list. + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowLeft" }); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + }); +}); + +describe("rtl", () => { + it("navigates down on ArrowLeft", async () => { + render(Main, { rtl: true, orientation: "horizontal" }); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowLeft" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowLeft" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowLeft" }); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + + // Reached the end of the list. + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowLeft" }); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + }); + + it("navigates up on ArrowRight", async () => { + render(Main, { rtl: true, orientation: "horizontal" }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowRight" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId("item-2")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowRight" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowRight" }); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + // Reached the end of the list. + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowRight" }); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + }); +}); + +describe("focusItemOnOpen", () => { + it("respects true", async () => { + render(Main, { focusItemOnOpen: true }); + + await fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + }); + + it("respects false", async () => { + render(Main, { focusItemOnOpen: false }); + + await fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getByTestId("item-0")).not.toHaveFocus()); + }); +}); + +describe("allowEscape + virtual", () => { + it("respects true", async () => { + render(Main, { allowEscape: true, virtual: true, loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => + expect(screen.getByTestId("item-0").getAttribute("aria-selected")).toBe( + "true", + ), + ); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowUp" }); + await waitFor(() => + expect(screen.getByTestId("item-0").getAttribute("aria-selected")).toBe( + "false", + ), + ); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => + expect(screen.getByTestId("item-0").getAttribute("aria-selected")).toBe( + "true", + ), + ); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => + expect(screen.getByTestId("item-1").getAttribute("aria-selected")).toBe( + "true", + ), + ); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => + expect(screen.getByTestId("item-2").getAttribute("aria-selected")).toBe( + "true", + ), + ); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => + expect(screen.getByTestId("item-2").getAttribute("aria-selected")).toBe( + "false", + ), + ); + }); + + it("respects false", async () => { + render(Main, { allowEscape: false, virtual: true, loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => + expect(screen.getByTestId("item-0").getAttribute("aria-selected")).toBe( + "true", + ), + ); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => + expect(screen.getByTestId("item-1").getAttribute("aria-selected")).toBe( + "true", + ), + ); + }); + + // TODO: Fix this test. + it.skip("true - calls `onNavigate` with `null` when escaped", async () => { + const spy = vi.fn(); + render(Main, { + allowEscape: true, + virtual: true, + loop: true, + onNavigate: spy, + }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowUp" }); + await waitFor(() => expect(spy).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(spy).toHaveBeenCalledWith(null)); + }); +}); + +describe("openOnArrowKeyDown", () => { + it("true ArrowDown opens", async () => { + render(Main, { openOnArrowKeyDown: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument()); + }); + + it("true ArrowUp opens", async () => { + render(Main, { openOnArrowKeyDown: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument()); + }); + + it("false ArrowDown does not open", async () => { + render(Main, { openOnArrowKeyDown: false }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => + expect(screen.queryByRole("menu")).not.toBeInTheDocument(), + ); + }); + + it("false ArrowUp does not open", async () => { + render(Main, { openOnArrowKeyDown: false }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowUp" }); + await waitFor(() => + expect(screen.queryByRole("menu")).not.toBeInTheDocument(), + ); + }); +}); + +describe("disabledIndices", () => { + it("skips disabled indices", async () => { + render(Main, { disabledIndices: [0] }); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + await fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowUp" }); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + }); +}); + +describe.skip("focusOnHover"); + +describe.skip("grid navigation"); + +describe.skip("grid navigation when items have different sizes"); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte new file mode 100644 index 00000000..13404e4d --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte @@ -0,0 +1,91 @@ + + + +{#if open} +
    +
      + {#each items as item, index (item)} +
    • { + inputValue = item; + open = false; + f.domReference?.focus(); + }, + })}> + {item} +
    • + {/each} +
    +
    +{/if} +
    {activeIndex}
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte new file mode 100644 index 00000000..d0cf71a8 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte @@ -0,0 +1,52 @@ + + + +{#if open} +
    +
      + {#each ["one", "two", "three"] as str, index (str)} +
    • + {str} +
    • + {/each} +
    +
    +{/if} From b1ce76b8bf1cee0b6f1397446f4a270e8d70202d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 20 Jan 2025 14:20:53 -0500 Subject: [PATCH 50/87] new --- .../src/hooks/use-list-navigation.svelte.ts | 1 + .../test/visual/components/new/main.svelte | 12 ++++++++++++ .../test/visual/routes/new/+page.svelte | 5 +++++ 3 files changed, 18 insertions(+) create mode 100644 packages/floating-ui-svelte/test/visual/components/new/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/new/+page.svelte diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index bb9b2d4d..42883c8d 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -472,6 +472,7 @@ class ListNavigationState { } #onNavigate = () => { + console.log("calling on navigate"); this.opts.onNavigate?.(this.#index === -1 ? null : this.#index); }; diff --git a/packages/floating-ui-svelte/test/visual/components/new/main.svelte b/packages/floating-ui-svelte/test/visual/components/new/main.svelte new file mode 100644 index 00000000..5082e694 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/new/main.svelte @@ -0,0 +1,12 @@ + + +

    New

    +
    +
    Reference
    +
    Floating
    +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/new/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/new/+page.svelte new file mode 100644 index 00000000..fbf8a00a --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/new/+page.svelte @@ -0,0 +1,5 @@ + + +
    From c86bd31253dea7d2d38f4c4de12b206976304b11 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 20 Jan 2025 14:47:12 -0500 Subject: [PATCH 51/87] fix --- .../src/hooks/use-list-navigation.svelte.ts | 38 +++++------- .../unit/hooks/use-list-navigation.test.ts | 3 +- .../test/visual/components/new/main.svelte | 60 ++++++++++++++++--- 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index 42883c8d..f874c97e 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -221,6 +221,7 @@ class ListNavigationState { #parentId: string | null = null; #tree: FloatingTreeType | null = null; #index = $state(this.#selectedIndex ?? -1); + #typeableComboboxReference = $derived.by(() => isTypeableCombobox(this.context.domReference), ); @@ -232,9 +233,8 @@ class ListNavigationState { #activeId = $state(); #virtualId = $state(); - #previousMounted: boolean; - #previousOpen: boolean; - #previousOnNavigate: () => void; + #mounted = false; + #previousOpen = false; #hasActiveIndex = $derived.by(() => this.#activeIndex != null); #ariaActiveDescendantProp = $derived.by(() => { @@ -273,9 +273,7 @@ class ListNavigationState { this.#virtualItemRef = this.opts.virtualItemRef; this.#parentId = useFloatingParentNodeId(); this.#tree = useFloatingTree(); - this.#previousMounted = !!this.context.floating; - this.#previousOpen = this.context.open; - this.#previousOnNavigate = this.#onNavigate; + this.#mounted = !!this.context.floating; // Sync `selectedIndex` to be the `activeIndex` upon opening the floating // element. Also, reset `activeIndex` upon closing the floating element. @@ -296,7 +294,7 @@ class ListNavigationState { this.#index = this.#selectedIndex; this.#onNavigate(); } - } else if (this.#previousMounted) { + } else if (this.#mounted) { this.#index = -1; this.#onNavigate(); } @@ -331,14 +329,14 @@ class ListNavigationState { } // Reset while the floating element was open (e.g. the list changed). - if (this.#previousMounted) { + if (this.#mounted) { this.#index = -1; this.#focusItem(); } // Initial sync. if ( - (!this.#previousOpen || !this.#previousMounted) && + (!this.#previousOpen || !this.#mounted) && this.#focusItemOnOpen && (this.#key != null || (this.#focusItemOnOpen === true && this.#key == null)) @@ -397,7 +395,7 @@ class ListNavigationState { this.context.floating || !this.#tree || this.#virtual || - !this.#previousMounted + !this.#mounted ) { return; } @@ -446,17 +444,13 @@ class ListNavigationState { ); $effect.pre(() => { - this.#previousMounted = !!this.context.floating; + this.#mounted = !!this.context.floating; }); $effect.pre(() => { this.#previousOpen = this.context.open; }); - $effect.pre(() => { - this.#previousOnNavigate = this.#onNavigate; - }); - $effect.pre(() => { this.#focusItemOnOpen = this.#focusItemOnOpenProp; }); @@ -472,7 +466,6 @@ class ListNavigationState { } #onNavigate = () => { - console.log("calling on navigate"); this.opts.onNavigate?.(this.#index === -1 ? null : this.#index); }; @@ -811,6 +804,7 @@ class ListNavigationState { #referenceOnKeyDown = (event: KeyboardEvent) => { this.#isPointerModality = false; + const isOpen = this.context.open; const isArrowKey = event.key.startsWith("Arrow"); const isHomeOrEndKey = ["Home", "End"].includes(event.key); @@ -831,7 +825,7 @@ class ListNavigationState { event.key === "Enter" || event.key.trim() === ""; - if (this.#virtual && this.context.open) { + if (this.#virtual && isOpen) { const rootNode = this.#tree?.nodes.find((node) => node.parentId == null); const deepestNode = this.#tree && rootNode @@ -879,9 +873,7 @@ class ListNavigationState { // If a floating element should not open on arrow key down, avoid // setting `activeIndex` while it's closed. - if (!this.context.open && !this.#openOnArrowKeyDown && isArrowKey) { - return; - } + if (!isOpen && !this.#openOnArrowKeyDown && isArrowKey) return; if (isNavigationKey) { this.#key = this.#nested && isMainKey ? null : event.key; @@ -891,7 +883,7 @@ class ListNavigationState { if (isCrossOpenKey) { stopEvent(event); - if (this.context.open) { + if (isOpen) { this.#index = getMinIndex(this.#listRef, this.#disabledIndices); this.#onNavigate(); } else { @@ -909,13 +901,13 @@ class ListNavigationState { stopEvent(event); - if (!this.context.open && this.#openOnArrowKeyDown) { + if (!isOpen && this.#openOnArrowKeyDown) { this.context.onOpenChange(true, event, "list-navigation"); } else { this.#commonOnKeyDown(event); } - if (this.context.open) { + if (isOpen) { this.#onNavigate(); } } diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts index 4bd6bb2f..d1a39706 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts @@ -273,8 +273,7 @@ describe("allowEscape + virtual", () => { ); }); - // TODO: Fix this test. - it.skip("true - calls `onNavigate` with `null` when escaped", async () => { + it("true - calls `onNavigate` with `null` when escaped", async () => { const spy = vi.fn(); render(Main, { allowEscape: true, diff --git a/packages/floating-ui-svelte/test/visual/components/new/main.svelte b/packages/floating-ui-svelte/test/visual/components/new/main.svelte index 5082e694..d809be40 100644 --- a/packages/floating-ui-svelte/test/visual/components/new/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/new/main.svelte @@ -1,12 +1,56 @@ -

    New

    -
    -
    Reference
    -
    Floating
    -
    + +{#if open} +
    +
      + {#each ["one", "two", "three"] as str, index (str)} +
    • + {str} +
    • + {/each} +
    +
    +{/if} From e07ab9fe7712e5fd55f9c6b083325eeb2680605a Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 20 Jan 2025 17:19:55 -0500 Subject: [PATCH 52/87] broken list items, need to figure out how to better sync updates --- .../floating-focus-manager.svelte | 1 - .../floating-list/floating-list.svelte | 77 ++ .../components/floating-list/hooks.svelte.ts | 107 +++ .../src/hooks/use-dismiss.svelte.ts | 4 +- .../src/hooks/use-list-navigation.svelte.ts | 14 +- packages/floating-ui-svelte/src/index.ts | 3 + .../src/internal/handle-guard-focus.ts | 7 +- .../unit/hooks/use-list-navigation.test.ts | 733 +++++++++++++++++- .../scheduled-option.svelte | 43 + .../use-list-navigation/scheduled.svelte | 44 ++ .../use-list-navigation/select-option.svelte | 26 + .../use-list-navigation/select.svelte | 56 ++ .../components/complex-grid/main.svelte | 114 +++ .../test/visual/components/grid/main.svelte | 82 ++ .../test/visual/components/new/main.svelte | 74 +- .../visual/routes/complex-grid/+page.svelte | 5 + .../test/visual/routes/grid/+page.svelte | 5 + 17 files changed, 1343 insertions(+), 52 deletions(-) create mode 100644 packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte create mode 100644 packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select-option.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/complex-grid/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/grid/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/complex-grid/+page.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/grid/+page.svelte diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index cde5440b..530dabbb 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -411,7 +411,6 @@ relatedTarget && movedToUnrelatedNode && !isPointerDown && - // Fix React 18 Strict Mode returnFocus due to double rendering. relatedTarget !== getPreviouslyFocusedElement() ) { preventReturnFocus = true; diff --git a/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte b/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte new file mode 100644 index 00000000..10c4f2ad --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte @@ -0,0 +1,77 @@ + + + + +{@render children()} diff --git a/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts b/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts new file mode 100644 index 00000000..f5645330 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts @@ -0,0 +1,107 @@ +import { SvelteMap } from "svelte/reactivity"; +import { extract } from "../../internal/extract.js"; +import type { MaybeGetter } from "../../types.js"; +import { Context } from "../../internal/context.js"; +import { watch } from "../../internal/watch.svelte.js"; + +type FloatingListContextType = { + register: (node: Node) => void; + unregister: (node: Node) => void; + map: SvelteMap; + elements: Array; + labels?: Array; +}; + +const FloatingListContext = new Context( + "FloatingListContext", +); + +interface UseListItemOptions { + label?: MaybeGetter; +} + +class ListItemState { + #label = $derived.by(() => extract(this.opts.label)); + #listContext: FloatingListContextType; + #index = $state(null); + #ref = $state(null); + + constructor(private readonly opts: UseListItemOptions = {}) { + this.#listContext = FloatingListContext.getOr({ + register: () => {}, + unregister: () => {}, + map: new SvelteMap(), + elements: [], + labels: [], + }); + + $effect(() => { + console.log("elements in listitemstate", this.#listContext.elements); + console.log("element0 in listitemstate", this.#listContext.elements[0]); + }); + + watch( + () => this.#ref, + () => { + const node = this.#ref; + if (node) { + this.#listContext.register(node); + return () => { + this.#listContext.unregister(node); + }; + } + }, + ); + + $effect.pre(() => { + const index = this.#ref ? this.#listContext.map.get(this.#ref) : null; + if (index != null) { + this.#index = index; + } + }); + } + + get index() { + return this.#index == null ? -1 : this.#index; + } + + get ref() { + return this.#ref as HTMLElement | null; + } + + set ref(node: HTMLElement | null) { + this.#ref = node; + const idx = this.#index; + const label = this.#label; + + if (idx !== null) { + this.#listContext.elements[idx] = node; + this.#listContext.elements = this.#listContext.elements; + if (this.#listContext.labels) { + if (label !== undefined) { + this.#listContext.labels[idx] = label; + } else { + this.#listContext.labels[idx] = node?.textContent ?? null; + } + } + } + } +} + +/** + * Used to register a list item and its index (DOM position) in the + * `FloatingList`. + */ +function useListItem(opts: UseListItemOptions = {}) { + return new ListItemState(opts); +} + +/** + * Used to register a list item and its index (DOM position) in the + * `FloatingList`. + */ + +// function useListItem(opts: UseListItemOptions = {}) + +export { FloatingListContext, useListItem, ListItemState }; +export type { UseListItemOptions }; diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index 9d3b68cc..9e7e907f 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -330,9 +330,7 @@ class DismissInteraction implements ElementProps { return; } - if (localInsideTree) { - return; - } + if (localInsideTree) return; if ( typeof this.#outsidePress === "function" && diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index f874c97e..4f3c9e3f 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -316,7 +316,8 @@ class ListNavigationState { () => this.#rtl, () => this.#disabledIndices, ], - () => { + (_, [__, prevOpen, prevFloating]) => { + const prevMounted = !!prevFloating; if (!this.#enabled) return; if (!this.context.open) return; if (!this.context.floating) return; @@ -329,20 +330,25 @@ class ListNavigationState { } // Reset while the floating element was open (e.g. the list changed). - if (this.#mounted) { + if (prevMounted) { this.#index = -1; this.#focusItem(); } // Initial sync. + console.log("focusItemOnOpen", this.#focusItemOnOpen); if ( - (!this.#previousOpen || !this.#mounted) && + (!prevOpen || !prevMounted) && this.#focusItemOnOpen && (this.#key != null || - (this.#focusItemOnOpen === true && this.#key == null)) + ((this.#focusItemOnOpen === true || + this.#focusItemOnOpen === "auto") && + this.#key == null)) ) { + console.log("running stuff"); let runs = 0; const waitForListPopulated = () => { + console.log("this list is null", this.#listRef[0] == null); if (this.#listRef[0] == null) { // Avoid letting the browser paint if possible on the first try, // otherwise use rAF. Don't try more than twice, since something diff --git a/packages/floating-ui-svelte/src/index.ts b/packages/floating-ui-svelte/src/index.ts index 6f26d91e..8ad4e755 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -23,6 +23,9 @@ export { default as FloatingOverlay } from "./components/floating-overlay.svelte export * from "./components/floating-delay-group.svelte"; export { default as FloatingDelayGroup } from "./components/floating-delay-group.svelte"; +export * from "./components/floating-list/floating-list.svelte"; +export { default as FloatingList } from "./components/floating-list/floating-list.svelte"; + /** * Hooks */ diff --git a/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts b/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts index 8e181af1..03e856aa 100644 --- a/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts +++ b/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts @@ -11,11 +11,14 @@ import { sleep } from "./sleep.js"; * restoring the attribute. */ -function handleGuardFocus(guard: HTMLElement | null) { +function handleGuardFocus( + guard: HTMLElement | null, + focusOptions?: Parameters[0], +) { if (!guard) return; const ariaHidden = guard.getAttribute("aria-hidden"); guard.removeAttribute("aria-hidden"); - guard.focus(); + guard.focus(focusOptions); sleep().then(() => { if (ariaHidden === null) { guard.setAttribute("aria-hidden", ""); diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts index d1a39706..04a9f306 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts @@ -1,9 +1,19 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; import { describe, expect, it, vi } from "vitest"; import Main from "./wrapper-components/use-list-navigation/main.svelte"; import Autocomplete from "./wrapper-components/use-list-navigation/autocomplete.svelte"; import { userEvent } from "@testing-library/user-event"; -import { testKbd } from "../../utils.js"; +import { sleep, testKbd } from "../../utils.js"; +import Grid from "../../visual/components/grid/main.svelte"; +import ComplexGrid from "../../visual/components/complex-grid/main.svelte"; +import Scheduled from "./wrapper-components/use-list-navigation/scheduled.svelte"; +import Select from "./wrapper-components/use-list-navigation/select.svelte"; it("opens on ArrowDown and focuses first item", async () => { render(Main); @@ -329,8 +339,721 @@ describe("disabledIndices", () => { }); }); -describe.skip("focusOnHover"); +describe("focusOnHover", () => { + it("true - focuses item on hover and syncs the active index", async () => { + const spy = vi.fn(); + render(Main, { onNavigate: spy }); + + await fireEvent.click(screen.getByRole("button")); + await fireEvent.mouseMove(screen.getByTestId("item-1")); + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); + await fireEvent.pointerLeave(screen.getByTestId("item-1")); + await waitFor(() => expect(screen.getByRole("menu")).toHaveFocus()); + await waitFor(() => expect(spy).toHaveBeenCalledWith(1)); + }); + + it("false - does not focus item on hover and does not sync the active index", async () => { + const spy = vi.fn(); + render(Main, { + onNavigate: spy, + focusItemOnOpen: false, + focusItemOnHover: false, + }); + await userEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getByRole("button")).toHaveFocus()); + await fireEvent.mouseMove(screen.getByTestId("item-1")); + expect(screen.getByTestId("item-1")).not.toHaveFocus(); + expect(spy).toHaveBeenCalledTimes(0); + }); +}); + +describe("grid navigation", () => { + it("focuses first item on ArrowDown", async () => { + render(Grid); + await fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.queryByRole("menu")).toBeInTheDocument()); + await fireEvent.keyDown(document, { key: "ArrowDown" }); + await waitFor(() => expect(screen.getAllByRole("option")[8]).toHaveFocus()); + }); + + it("focuses first non-disabled item in grid", async () => { + render(Grid); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getAllByRole("option")[8]).toHaveFocus()); + }); + + it("focuses next item using ArrowRight ke, skipping disabled items", async () => { + render(Grid); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[9]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[11]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[14]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[16]).toHaveFocus(); + }); + + it("focuses previous item using ArrowLeft key, skipping disabled items", async () => { + render(Grid); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[47].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + expect(screen.getAllByRole("option")[46]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[44]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + expect(screen.getAllByRole("option")[41]).toHaveFocus(); + }); + + it("skips row and remains on same column when pressing ArrowDown", async () => { + render(Grid); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + expect(screen.getAllByRole("option")[13]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + expect(screen.getAllByRole("option")[18]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + expect(screen.getAllByRole("option")[23]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + expect(screen.getAllByRole("option")[28]).toHaveFocus(); + }); + + it("skips row and remains on same column when pressing ArrowUp", async () => { + render(Grid); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[47].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + expect(screen.getAllByRole("option")[42]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + expect(screen.getAllByRole("option")[37]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + expect(screen.getAllByRole("option")[32]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + expect(screen.getAllByRole("option")[27]).toHaveFocus(); + }); + + it("loops on the same column with ArrowDown", async () => { + render(Grid, { loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + + expect(screen.getAllByRole("option")[8]).toHaveFocus(); + }); + + it("loops on the same column with ArrowUp", async () => { + render(Grid, { loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[43].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + await fireEvent.keyDown(screen.getByTestId("floating"), { key: "ArrowUp" }); + + expect(screen.getAllByRole("option")[43]).toHaveFocus(); + }); + + it("does not leave row with 'both' orientation while looping", async () => { + render(Grid, { orientation: "both", loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[9]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[8]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + expect(screen.getAllByRole("option")[9]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + expect(screen.getAllByRole("option")[8]).toHaveFocus(); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + expect(screen.getAllByRole("option")[13]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[14]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[11]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + expect(screen.getAllByRole("option")[14]).toHaveFocus(); + }); + + it("loops on the last row", async () => { + render(Grid, { orientation: "both", loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[46].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[47]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowRight", + }); + expect(screen.getAllByRole("option")[46]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + expect(screen.getAllByRole("option")[47]).toHaveFocus(); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowLeft", + }); + expect(screen.getAllByRole("option")[46]).toHaveFocus(); + }); +}); + +describe("grid navigation when items have different sizes", () => { + it("focuses first non-disabled item in a grid", async () => { + render(ComplexGrid); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getAllByRole("option")[7]).toHaveFocus()); + }); + + describe.each([ + { rtl: false, arrowToStart: "ArrowLeft", arrowToEnd: "ArrowRight" }, + { rtl: true, arrowToStart: "ArrowRight", arrowToEnd: "ArrowLeft" }, + ])("with rtl $rtl", ({ rtl, arrowToStart, arrowToEnd }) => { + it(`focuses next item using ${arrowToEnd} key, skipping disabled items`, async () => { + render(ComplexGrid, { rtl }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[8]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[10]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[13]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[15]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[20]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[24]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[34]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[36]).toHaveFocus(), + ); + }); + + it(`focuses previous item using ${arrowToStart} key, skipping disabled items`, async () => { + render(ComplexGrid, { rtl }); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[36].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[34]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[28]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[20]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[7]).toHaveFocus(), + ); + }); + + it(`moves through rows when pressing ArrowDown, prefers ${rtl ? "right" : "left"} side of wide items`, async () => { + render(ComplexGrid, { rtl }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[20]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[25]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[31]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[36]).toHaveFocus(), + ); + }); + + it(`moves through rows when pressing ArrowUp, prefers ${rtl ? "right" : "left"} side of wide items`, async () => { + render(ComplexGrid, { rtl }); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[29].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[21]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[15]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[8]).toHaveFocus(), + ); + }); + + it(`loops over column with ArrowDown, prefers ${rtl ? "right" : "left"} side of wide items`, async () => { + render(ComplexGrid, { rtl, loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + + await waitFor(() => + expect(screen.getAllByRole("option")[13]).toHaveFocus(), + ); + }); + + it(`loops over column with ArrowUp, prefers ${rtl ? "right" : "left"} side of wide items`, async () => { + render(ComplexGrid, { rtl, loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[30].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowUp", + }); + + await waitFor(() => + expect(screen.getAllByRole("option")[8]).toHaveFocus(), + ); + }); + + it("loops over row with 'both' orientation, prefers top side of tall items", async () => { + render(ComplexGrid, { rtl, orientation: "both", loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[20].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[21]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[20]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[21]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToStart, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[21]).toHaveFocus(), + ); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: "ArrowDown", + }); + await waitFor(() => + expect(screen.getAllByRole("option")[22]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[24]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[20]).toHaveFocus(), + ); + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[21]).toHaveFocus(), + ); + }); + + it("handles looping on the last row", async () => { + render(ComplexGrid, { rtl, orientation: "both", loop: true }); + await fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + await fireEvent.click(screen.getByRole("button")); + + await act(() => screen.getAllByRole("option")[36].focus()); + + await fireEvent.keyDown(screen.getByTestId("floating"), { + key: arrowToEnd, + }); + await waitFor(() => + expect(screen.getAllByRole("option")[36]).toHaveFocus(), + ); + }); + }); +}); + +it("handles scheduled list population", async () => { + render(Scheduled); + + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowUp" }); + + await act(async () => {}); + + await waitFor(() => expect(screen.getAllByRole("option")[2]).toHaveFocus()); + + await fireEvent.click(screen.getByRole("button")); + await fireEvent.keyDown(screen.getByRole("button"), { key: "ArrowDown" }); + + await act(async () => {}); + + await waitFor(() => expect(screen.getAllByRole("option")[0]).toHaveFocus()); +}); + +it.only("async selectedIndex", async () => { + render(Select); + + await fireEvent.click(screen.getByRole("button")); + await act(async () => {}); + + await waitFor(() => expect(screen.getAllByRole("option")[2]).toHaveFocus()); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await waitFor(() => expect(screen.getAllByRole("option")[3]).toHaveFocus()); +}); + +it.todo("grid navigation with changing list items"); + +it.todo("grid navigation with disabled list items"); + +it.todo("selectedIndex changing does not steal focus"); + +it.todo("focus management in nested lists"); + +it.todo("virtual nested home or end key presses"); -describe.skip("grid navigation"); +it.todo("domReference trigger in nested virtual menu is set as virtual item"); -describe.skip("grid navigation when items have different sizes"); +it.todo("Home or End key press is ignored for typeable combobox reference"); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte new file mode 100644 index 00000000..5d339889 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte @@ -0,0 +1,43 @@ + + +
    + opt +
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled.svelte new file mode 100644 index 00000000..be2a81d6 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled.svelte @@ -0,0 +1,44 @@ + + + +{#if open} +
    + {#each ["one", "two", "three"] as option, index (option)} + + {/each} +
    +{/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select-option.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select-option.svelte new file mode 100644 index 00000000..16ece6c9 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select-option.svelte @@ -0,0 +1,26 @@ + + + diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select.svelte new file mode 100644 index 00000000..3161e4a3 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select.svelte @@ -0,0 +1,56 @@ + + + +{#if open} + +
    + + {#each options as option (option)} + + {/each} + +
    +
    +{/if} diff --git a/packages/floating-ui-svelte/test/visual/components/complex-grid/main.svelte b/packages/floating-ui-svelte/test/visual/components/complex-grid/main.svelte new file mode 100644 index 00000000..a000b243 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/complex-grid/main.svelte @@ -0,0 +1,114 @@ + + +

    Complex Grid

    +
    + + {#if open} + +
    + {#each { length: 37 } as _, index (index)} + + {/each} +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/visual/components/grid/main.svelte b/packages/floating-ui-svelte/test/visual/components/grid/main.svelte new file mode 100644 index 00000000..9beecdd0 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/grid/main.svelte @@ -0,0 +1,82 @@ + + +

    Grid

    +
    + + {#if open} + + + + {/if} +
    diff --git a/packages/floating-ui-svelte/test/visual/components/new/main.svelte b/packages/floating-ui-svelte/test/visual/components/new/main.svelte index d809be40..583f7dc2 100644 --- a/packages/floating-ui-svelte/test/visual/components/new/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/new/main.svelte @@ -4,53 +4,53 @@ useFloating, useInteractions, useListNavigation, - type UseListNavigationOptions, } from "../../../../src/index.js"; + import FloatingFocusManager from "../../../../src/components/floating-focus-manager/floating-focus-manager.svelte"; + import FloatingList from "../../../../src/components/floating-list/floating-list.svelte"; + import SelectOption from "../../../unit/hooks/wrapper-components/use-list-navigation/select-option.svelte"; - let props: Omit, "listRef"> = $props(); - - let open = $state(false); - let listRef = $state>([]); let activeIndex = $state(null); + let selectedIndex = $state(2); + let open = $state(false); const f = useFloating({ open: () => open, onOpenChange: (v) => (open = v), }); - const ints = useInteractions([ - useClick(f.context), - useListNavigation(f.context, { - ...props, - allowEscape: true, - virtual: true, - loop: true, - listRef: () => listRef, - activeIndex: () => activeIndex, - onNavigate: (index) => { - activeIndex = index; - console.log("onNavigateCalled"); - props.onNavigate?.(index); - }, - }), - ]); + let elements = $state>([]); + + const click = useClick(f.context); + const listNav = useListNavigation(f.context, { + listRef: () => elements, + activeIndex: () => activeIndex, + selectedIndex: () => selectedIndex, + onNavigate: (idx) => (activeIndex = idx), + }); + + const ints = useInteractions([listNav, click]); + const options = [ + "core", + "dom", + "react", + "react-dom", + "vue", + "react-native", + ]; - + {#if open} -
    -
      - {#each ["one", "two", "three"] as str, index (str)} -
    • - {str} -
    • - {/each} -
    -
    + +
    + + {#each options as option (option)} + + {/each} + +
    +
    {/if} diff --git a/packages/floating-ui-svelte/test/visual/routes/complex-grid/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/complex-grid/+page.svelte new file mode 100644 index 00000000..3c36f3e9 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/complex-grid/+page.svelte @@ -0,0 +1,5 @@ + + +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/grid/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/grid/+page.svelte new file mode 100644 index 00000000..84982fa8 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/grid/+page.svelte @@ -0,0 +1,5 @@ + + +
    From df21cb6746f60130176ce901bcd499d58cea5604 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 20 Jan 2025 21:07:00 -0500 Subject: [PATCH 53/87] woohoo --- .../floating-list/floating-list.svelte | 14 +- .../components/floating-list/hooks.svelte.ts | 125 ++- .../src/hooks/use-list-navigation.svelte.ts | 875 ++++++++---------- .../src/internal/extract.ts | 15 +- .../unit/hooks/use-list-navigation.test.ts | 8 +- .../use-list-navigation/autocomplete.svelte | 4 +- .../scheduled-option.svelte | 4 +- .../test/visual/components/new/main.svelte | 105 ++- 8 files changed, 546 insertions(+), 604 deletions(-) diff --git a/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte b/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte index 10c4f2ad..c6eaa418 100644 --- a/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte +++ b/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte @@ -51,23 +51,25 @@ function register(node: Node) { map.set(node, null); + const nodes = Array.from(map.keys()).sort(sortByDocumentPosition); + nodes.forEach((node, index) => { + map.set(node, index); + }); } function unregister(node: Node) { map.delete(node); - } - - $effect.pre(() => { const nodes = Array.from(map.keys()).sort(sortByDocumentPosition); - nodes.forEach((node, index) => { map.set(node, index); }); - }); + } FloatingListContext.set({ elements, - labels, + get labels() { + return labels; + }, map, register, unregister, diff --git a/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts b/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts index f5645330..1156a4ec 100644 --- a/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts +++ b/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts @@ -1,8 +1,8 @@ -import { SvelteMap } from "svelte/reactivity"; import { extract } from "../../internal/extract.js"; import type { MaybeGetter } from "../../types.js"; import { Context } from "../../internal/context.js"; import { watch } from "../../internal/watch.svelte.js"; +import type { SvelteMap } from "svelte/reactivity"; type FloatingListContextType = { register: (node: Node) => void; @@ -20,88 +20,67 @@ interface UseListItemOptions { label?: MaybeGetter; } -class ListItemState { - #label = $derived.by(() => extract(this.opts.label)); - #listContext: FloatingListContextType; - #index = $state(null); - #ref = $state(null); - - constructor(private readonly opts: UseListItemOptions = {}) { - this.#listContext = FloatingListContext.getOr({ - register: () => {}, - unregister: () => {}, - map: new SvelteMap(), - elements: [], - labels: [], - }); - - $effect(() => { - console.log("elements in listitemstate", this.#listContext.elements); - console.log("element0 in listitemstate", this.#listContext.elements[0]); - }); - - watch( - () => this.#ref, - () => { - const node = this.#ref; +/** + * Used to register a list item and its index (DOM position) in the + * `FloatingList`. + */ +function useListItem(opts: UseListItemOptions = {}) { + const label = $derived(extract(opts.label)); + const listContext = FloatingListContext.get(); + let index = $state(null); + let ref = $state(null); + + watch( + () => ref, + () => { + const node = ref; + return () => { if (node) { - this.#listContext.register(node); - return () => { - this.#listContext.unregister(node); - }; + listContext.unregister(node); } - }, - ); - - $effect.pre(() => { - const index = this.#ref ? this.#listContext.map.get(this.#ref) : null; - if (index != null) { - this.#index = index; + }; + }, + ); + + $effect(() => { + const localIndex = ref ? listContext.map.get(ref) : null; + if (localIndex != null) { + index = localIndex; + } + }); + + return { + get index() { + return index == null ? -1 : index; + }, + get ref() { + return ref as HTMLElement | null; + }, + set ref(node: HTMLElement | null) { + ref = node; + if (node) { + listContext.register(node); } - }); - } - - get index() { - return this.#index == null ? -1 : this.#index; - } - - get ref() { - return this.#ref as HTMLElement | null; - } - - set ref(node: HTMLElement | null) { - this.#ref = node; - const idx = this.#index; - const label = this.#label; + const idx = node ? listContext.map.get(node) : null; + if (idx === undefined) return; + if (idx != null) { + index = idx; + } + if (idx === null) return; - if (idx !== null) { - this.#listContext.elements[idx] = node; - this.#listContext.elements = this.#listContext.elements; - if (this.#listContext.labels) { + listContext.elements[idx] = node; + if (listContext.labels) { if (label !== undefined) { - this.#listContext.labels[idx] = label; + listContext.labels[idx] = label; } else { - this.#listContext.labels[idx] = node?.textContent ?? null; + listContext.labels[idx] = node?.textContent ?? null; } } - } - } + }, + }; } -/** - * Used to register a list item and its index (DOM position) in the - * `FloatingList`. - */ -function useListItem(opts: UseListItemOptions = {}) { - return new ListItemState(opts); -} - -/** - * Used to register a list item and its index (DOM position) in the - * `FloatingList`. - */ - // function useListItem(opts: UseListItemOptions = {}) -export { FloatingListContext, useListItem, ListItemState }; +export { FloatingListContext, useListItem }; export type { UseListItemOptions }; diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index 4f3c9e3f..4fb94e8e 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -18,7 +18,6 @@ import { isTypeableCombobox } from "../internal/is-typeable-element.js"; import { enqueueFocus } from "../internal/enqueue-focus.js"; import type { FocusEventHandler, - KeyboardEventHandler, MouseEventHandler, PointerEventHandler, } from "svelte/elements"; @@ -185,368 +184,268 @@ interface UseListNavigationOptions { dense?: MaybeGetter; } -class ListNavigationState { - #listRef = $derived.by(() => extract(this.opts?.listRef)); - #activeIndex = $derived.by(() => extract(this.opts?.activeIndex)); - #enabled = $derived.by(() => extract(this.opts?.enabled, true)); - #selectedIndex = $derived.by(() => extract(this.opts?.selectedIndex, null)); - #allowEscape = $derived.by(() => extract(this.opts?.allowEscape, false)); - #loop = $derived.by(() => extract(this.opts?.loop, false)); - #nested = $derived.by(() => extract(this.opts?.nested, false)); - #rtl = $derived.by(() => extract(this.opts?.rtl, false)); - #virtual = $derived.by(() => extract(this.opts?.virtual, false)); - #focusItemOnOpenProp = $derived.by(() => - extract(this.opts?.focusItemOnOpen, "auto"), - ); - #focusItemOnHover = $derived.by(() => - extract(this.opts?.focusItemOnHover, true), - ); - #openOnArrowKeyDown = $derived.by(() => - extract(this.opts?.openOnArrowKeyDown, true), - ); - #disabledIndices = $derived.by(() => - extract(this.opts?.disabledIndices, undefined), - ); - #orientation = $derived.by(() => extract(this.opts?.orientation, "vertical")); - #cols = $derived.by(() => extract(this.opts?.cols, 1)); - #scrollItemIntoView = $derived.by(() => - extract(this.opts?.scrollItemIntoView, true), - ); - #virtualItemRef: UseListNavigationOptions["virtualItemRef"]; - #itemSizes = $derived.by(() => extract(this.opts?.itemSizes)); - #dense = $derived.by(() => extract(this.opts?.dense, false)); - #floatingFocusElement = $derived.by(() => - getFloatingFocusElement(this.context.floating), +function useListNavigation( + context: FloatingContext | FloatingRootContext, + opts: UseListNavigationOptions, +) { + const { virtualItemRef, onNavigate: onNavigateProp } = opts; + const listRef = $derived(extract(opts.listRef)); + const selectedIndex = $derived(extract(opts.selectedIndex, null)); + const activeIndex = $derived(extract(opts.activeIndex, null)); + const enabled = $derived(extract(opts.enabled, true)); + const allowEscape = $derived(extract(opts.allowEscape, false)); + const loop = $derived(extract(opts.loop, false)); + const nested = $derived(extract(opts.nested, false)); + const rtl = $derived(extract(opts.rtl, false)); + const virtual = $derived(extract(opts.virtual, false)); + const focusItemOnOpenProp = $derived(extract(opts.focusItemOnOpen, "auto")); + const focusItemOnHover = $derived(extract(opts.focusItemOnHover, true)); + const openOnArrowKeyDown = $derived(extract(opts.openOnArrowKeyDown, true)); + const disabledIndices = $derived(extract(opts.disabledIndices, undefined)); + const orientation = $derived(extract(opts.orientation, "vertical")); + const cols = $derived(extract(opts.cols, 1)); + const scrollItemIntoView = $derived(extract(opts.scrollItemIntoView, true)); + const itemSizes = $derived(extract(opts.itemSizes)); + const dense = $derived(extract(opts.dense, false)); + const floatingFocusElement = $derived( + getFloatingFocusElement(context.floating), ); - #parentId: string | null = null; - #tree: FloatingTreeType | null = null; - #index = $state(this.#selectedIndex ?? -1); - - #typeableComboboxReference = $derived.by(() => - isTypeableCombobox(this.context.domReference), + const parentId = useFloatingParentNodeId(); + const tree = useFloatingTree(); + const typeableComboboxReference = $derived( + isTypeableCombobox(context.domReference), ); - #key: string | null = null; - #isPointerModality = true; - #forceSyncFocus = false; - #forceScrollIntoView = false; - - #activeId = $state(); - #virtualId = $state(); - #mounted = false; - #previousOpen = false; - #hasActiveIndex = $derived.by(() => this.#activeIndex != null); + const hasActiveIndex = $derived(activeIndex != null); - #ariaActiveDescendantProp = $derived.by(() => { - if (this.#virtual && this.context.open && this.#hasActiveIndex) { + const ariaActiveDescendantProp = $derived.by(() => { + if (virtual && context.open && hasActiveIndex) { return { - "aria-activedescendant": this.#virtualId || this.#activeId, + "aria-activedescendant": virtualId || activeId, }; } return {}; }); - #focusItemOnOpen = $state(this.#focusItemOnOpenProp); - - constructor( - private readonly context: FloatingContext | FloatingRootContext, - private readonly opts: UseListNavigationOptions, - ) { - if (DEV) { - if (this.#allowEscape) { - if (!this.#loop) { - warn("`useListNavigation` looping must be enabled to allow escaping"); - } + let index = $state(selectedIndex ?? -1); + let key: string | null = null; + let isPointerModality = true; + let forceSyncFocus = false; + let forceScrollIntoView = false; + let activeId = $state(); + let virtualId = $state(); + let mounted = !!context.floating; + let focusItemOnOpen = $state(focusItemOnOpenProp); + + const onNavigate = () => { + onNavigateProp?.(index === -1 ? null : index); + }; - if (!this.#virtual) { - warn("`useListNavigation` must be virtual to allow escaping"); - } + if (DEV) { + if (allowEscape) { + if (!loop) { + warn("`useListNavigation` looping must be enabled to allow escaping"); } - if (this.#orientation === "vertical" && this.#cols > 1) { - warn( - "In grid list navigation mode (`cols` > 1), the `orientation` should", - 'be either "horizontal" or "both".', - ); + if (!virtual) { + warn("`useListNavigation` must be virtual to allow escaping"); } } - this.#virtualItemRef = this.opts.virtualItemRef; - this.#parentId = useFloatingParentNodeId(); - this.#tree = useFloatingTree(); - this.#mounted = !!this.context.floating; - - // Sync `selectedIndex` to be the `activeIndex` upon opening the floating - // element. Also, reset `activeIndex` upon closing the floating element. - watch.pre( - [ - () => this.#enabled, - () => this.context.open, - () => this.context.floating, - () => this.#selectedIndex, - ], - () => { - if (!this.#enabled) return; - if (this.context.open && this.context.floating) { - if (this.#focusItemOnOpen && this.#selectedIndex != null) { - // Regardless of the pointer modality, we want to ensure the selected - // item comes into view when the floating element is opened. - this.#forceScrollIntoView = true; - this.#index = this.#selectedIndex; - this.#onNavigate(); - } - } else if (this.#mounted) { - this.#index = -1; - this.#onNavigate(); - } - }, - ); - - // Sync `activeIndex` to be the focused item while the floating element is - // open. - watch.pre( - [ - () => this.#enabled, - () => this.context.open, - () => this.context.floating, - () => this.#activeIndex, - () => this.#selectedIndex, - () => this.#nested, - () => this.#listRef, - () => this.#orientation, - () => this.#rtl, - () => this.#disabledIndices, - ], - (_, [__, prevOpen, prevFloating]) => { - const prevMounted = !!prevFloating; - if (!this.#enabled) return; - if (!this.context.open) return; - if (!this.context.floating) return; - - if (this.#activeIndex == null) { - this.#forceSyncFocus = false; - - if (this.#selectedIndex != null) { - return; - } - - // Reset while the floating element was open (e.g. the list changed). - if (prevMounted) { - this.#index = -1; - this.#focusItem(); - } - // Initial sync. - console.log("focusItemOnOpen", this.#focusItemOnOpen); - if ( - (!prevOpen || !prevMounted) && - this.#focusItemOnOpen && - (this.#key != null || - ((this.#focusItemOnOpen === true || - this.#focusItemOnOpen === "auto") && - this.#key == null)) - ) { - console.log("running stuff"); - let runs = 0; - const waitForListPopulated = () => { - console.log("this list is null", this.#listRef[0] == null); - if (this.#listRef[0] == null) { - // Avoid letting the browser paint if possible on the first try, - // otherwise use rAF. Don't try more than twice, since something - // is wrong otherwise. - if (runs < 2) { - const scheduler = runs - ? requestAnimationFrame - : queueMicrotask; - scheduler(waitForListPopulated); - } - runs++; - } else { - this.#index = - this.#key == null || - isMainOrientationToEndKey( - this.#key, - this.#orientation, - this.#rtl, - ) || - this.#nested - ? getMinIndex(this.#listRef, this.#disabledIndices) - : getMaxIndex(this.#listRef, this.#disabledIndices); - this.#key = null; - this.#onNavigate(); - } - }; + if (orientation === "vertical" && cols > 1) { + warn( + "In grid list navigation mode (`cols` > 1), the `orientation` should", + 'be either "horizontal" or "both".', + ); + } + } - waitForListPopulated(); - } - } else if (!isIndexOutOfBounds(this.#listRef, this.#activeIndex)) { - this.#index = this.#activeIndex; - this.#focusItem(); - this.#forceScrollIntoView = false; + // Sync `selectedIndex` to be the `activeIndex` upon opening the floating + // element. Also, reset `activeIndex` upon closing the floating element. + watch.pre( + [ + () => enabled, + () => context.open, + () => context.floating, + () => selectedIndex, + ], + (_, [__, ___, prevFloating]) => { + if (!enabled) return; + const prevMounted = !!prevFloating; + if (context.open && context.floating) { + if (focusItemOnOpen && selectedIndex != null) { + // Regardless of the pointer modality, we want to ensure the selected + // item comes into view when the floating element is opened. + forceScrollIntoView = true; + index = selectedIndex; + onNavigate(); } - }, - ); + } else if (prevMounted) { + index = -1; + onNavigate(); + } + }, + ); - // Ensure the parent floating element has focus when a nested child closes - // to allow arrow key navigation to work after the pointer leaves the child. - watch.pre( - [ - () => this.#enabled, - () => this.context.floating, - () => this.#tree?.nodes, - () => this.#virtual, - ], - () => { - if ( - !this.#enabled || - this.context.floating || - !this.#tree || - this.#virtual || - !this.#mounted - ) { + // Sync `activeIndex` to be the focused item while the floating element is + // open. + watch.pre( + [ + () => enabled, + () => context.open, + () => context.floating, + () => activeIndex, + () => selectedIndex, + () => nested, + () => listRef, + () => orientation, + () => rtl, + () => disabledIndices, + ], + (_, [__, prevOpen, prevFloating]) => { + const prevMounted = !!prevFloating; + if (!enabled) return; + if (!context.open) return; + if (!context.floating) return; + + if (activeIndex == null) { + forceSyncFocus = false; + + if (selectedIndex != null) { return; } - const nodes = this.#tree.nodes; - const parent = nodes.find((node) => node.id === this.#parentId)?.context - ?.floating; - const activeEl = activeElement(getDocument(this.context.floating)); - const treeContainsActiveEl = nodes.some( - (node) => node.context && contains(node.context.floating, activeEl), - ); - - if (parent && !treeContainsActiveEl && this.#isPointerModality) { - parent.focus({ preventScroll: true }); + // Reset while the floating element was open (e.g. the list changed). + if (prevMounted) { + index = -1; + focusItem(); } - }, - ); - - watch.pre( - [ - () => this.#enabled, - () => this.#tree?.nodes, - () => this.#virtual, - () => this.#virtualItemRef?.current, - ], - () => { - if (!this.#enabled) return; - if (!this.#tree) return; - if (!this.#virtual) return; - if (this.#parentId) return; - - const handleVirtualFocus = (item: HTMLElement) => { - this.#virtualId = item.id; - if (this.#virtualItemRef) { - this.#virtualItemRef.current = item; - } - }; - const tree = this.#tree; - - tree.events.on("virtualfocus", handleVirtualFocus); - return () => { - tree.events.off("virtualfocus", handleVirtualFocus); - }; - }, - ); - - $effect.pre(() => { - this.#mounted = !!this.context.floating; - }); - - $effect.pre(() => { - this.#previousOpen = this.context.open; - }); - - $effect.pre(() => { - this.#focusItemOnOpen = this.#focusItemOnOpenProp; - }); - - watch.pre( - () => this.context.open, - () => { - if (!this.context.open) { - this.#key = null; + // Initial sync. + if ( + (!prevOpen || !prevMounted) && + focusItemOnOpen && + (key != null || (focusItemOnOpen === true && key == null)) + ) { + let runs = 0; + const waitForListPopulated = () => { + if (listRef[0] == null) { + // Avoid letting the browser paint if possible on the first try, + // otherwise use rAF. Don't try more than twice, since something + // is wrong otherwise. + if (runs < 2) { + const scheduler = runs ? requestAnimationFrame : queueMicrotask; + scheduler(waitForListPopulated); + } + runs++; + } else { + index = + key == null || + isMainOrientationToEndKey(key, orientation, rtl) || + nested + ? getMinIndex(listRef, disabledIndices) + : getMaxIndex(listRef, disabledIndices); + key = null; + onNavigate(); + } + }; + + waitForListPopulated(); } - }, - ); - } - - #onNavigate = () => { - this.opts.onNavigate?.(this.#index === -1 ? null : this.#index); - }; - - #syncCurrentTarget = (currentTarget: HTMLElement | null) => { - if (!this.context.open) return; - const index = this.#listRef.indexOf(currentTarget); - if (index !== -1 && this.#index !== index) { - this.#index = index; - this.#onNavigate(); - } - }; + } else if (!isIndexOutOfBounds(listRef, activeIndex)) { + index = activeIndex; + focusItem(); + forceScrollIntoView = false; + } + }, + ); - #itemOnFocus: FocusEventHandler = ({ currentTarget }) => { - this.#forceSyncFocus = true; - this.#syncCurrentTarget(currentTarget); - }; + // Ensure the parent floating element has focus when a nested child closes + // to allow arrow key navigation to work after the pointer leaves the child. + watch.pre( + [() => enabled, () => context.floating, () => tree?.nodes, () => virtual], + () => { + if (!enabled || context.floating || !tree || virtual || !mounted) return; + + const nodes = tree.nodes; + const parent = nodes.find((node) => node.id === parentId)?.context + ?.floating; + const activeEl = activeElement(getDocument(context.floating)); + const treeContainsActiveEl = nodes.some( + (node) => node.context && contains(node.context.floating, activeEl), + ); - #itemOnClick: MouseEventHandler = ({ currentTarget }) => - currentTarget.focus({ preventScroll: true }); // safari + if (parent && !treeContainsActiveEl && isPointerModality) { + parent.focus({ preventScroll: true }); + } + }, + ); - #itemOnMouseMove: MouseEventHandler = ({ currentTarget }) => { - this.#forceSyncFocus = true; - this.#forceScrollIntoView = false; - this.#syncCurrentTarget(currentTarget); - }; + watch.pre( + [ + () => enabled, + () => tree?.nodes, + () => virtual, + () => virtualItemRef?.current, + ], + () => { + if (!enabled) return; + if (!tree) return; + if (!virtual) return; + if (parentId) return; + + const handleVirtualFocus = (item: HTMLElement) => { + virtualId = item.id; + if (virtualItemRef) { + virtualItemRef.current = item; + } + }; + const localTree = tree; - #itemOnPointerLeave: PointerEventHandler = ({ pointerType }) => { - if (!this.#isPointerModality || pointerType === "touch") return; + localTree.events.on("virtualfocus", handleVirtualFocus); - this.#forceSyncFocus = true; - this.#index = -1; - this.#onNavigate(); + return () => { + localTree.events.off("virtualfocus", handleVirtualFocus); + }; + }, + ); - if (!this.#virtual) { - this.#floatingFocusElement?.focus({ preventScroll: true }); - } - }; + $effect.pre(() => { + mounted = !!context.floating; + }); - #item: ElementProps["item"] = $derived.by(() => ({ - onfocus: this.#itemOnFocus, - onclick: this.#itemOnClick, - ...(this.#focusItemOnHover && { - onmousemove: this.#itemOnMouseMove, - onpointerleave: this.#itemOnPointerLeave, - }), - })); + $effect.pre(() => { + focusItemOnOpen = focusItemOnOpenProp; + }); - #focusItem() { + function focusItem() { const runFocus = (item: HTMLElement) => { - if (this.#virtual) { - this.#activeId = item.id; - this.#tree?.events.emit("virtualfocus", item); - if (this.#virtualItemRef) { - this.#virtualItemRef.current = item; + if (virtual) { + activeId = item.id; + tree?.events.emit("virtualfocus", item); + if (virtualItemRef) { + virtualItemRef.current = item; } } else { enqueueFocus(item, { - sync: this.#forceSyncFocus, + sync: forceSyncFocus, preventScroll: true, }); } }; - const initialItem = this.#listRef[this.#index]; + const initialItem = listRef[index]; if (initialItem) { runFocus(initialItem); } - const scheduler = this.#forceSyncFocus + const scheduler = forceSyncFocus ? (v: () => void) => v() : requestAnimationFrame; scheduler(() => { - const waitedItem = this.#listRef[this.#index] || initialItem; + const waitedItem = listRef[index] || initialItem; if (!waitedItem) return; @@ -554,11 +453,11 @@ class ListNavigationState { runFocus(waitedItem); } - const scrollIntoViewOptions = this.#scrollItemIntoView; + const scrollIntoViewOptions = scrollItemIntoView; const shouldScrollIntoView = scrollIntoViewOptions && - this.#item && - (this.#forceScrollIntoView || !this.#isPointerModality); + item && + (forceScrollIntoView || !isPointerModality); if (shouldScrollIntoView) { // JSDOM doesn't support `.scrollIntoView()` but it's widely supported @@ -572,9 +471,57 @@ class ListNavigationState { }); } - #commonOnKeyDown = (event: KeyboardEvent) => { - this.#isPointerModality = true; - this.#forceSyncFocus = true; + const syncCurrentTarget = (currentTarget: HTMLElement | null) => { + if (!context.open) return; + const localIndex = listRef.indexOf(currentTarget); + if (localIndex !== -1 && index !== localIndex) { + index = localIndex; + onNavigate(); + } + }; + + const itemOnFocus: FocusEventHandler = ({ currentTarget }) => { + forceSyncFocus = true; + syncCurrentTarget(currentTarget); + }; + + const itemOnClick: MouseEventHandler = ({ currentTarget }) => + currentTarget.focus({ preventScroll: true }); // safari + + const itemOnMouseMove: MouseEventHandler = ({ + currentTarget, + }) => { + forceSyncFocus = true; + forceScrollIntoView = false; + syncCurrentTarget(currentTarget); + }; + + const itemOnPointerLeave: PointerEventHandler = ({ + pointerType, + }) => { + if (!isPointerModality || pointerType === "touch") return; + + forceSyncFocus = true; + index = -1; + onNavigate(); + + if (!virtual) { + floatingFocusElement?.focus({ preventScroll: true }); + } + }; + + const item: ElementProps["item"] = $derived({ + onfocus: itemOnFocus, + onclick: itemOnClick, + ...(focusItemOnHover && { + onmousemove: itemOnMouseMove, + onpointerleave: itemOnPointerLeave, + }), + }); + + const commonOnKeyDown = (event: KeyboardEvent) => { + isPointerModality = true; + forceSyncFocus = true; // When composing a character, Chrome fires ArrowDown twice. Firefox/Safari // don't appear to suffer from this. `event.isComposing` is avoided due to @@ -585,94 +532,84 @@ class ListNavigationState { // If the floating element is animating out, ignore navigation. Otherwise, // the `activeIndex` gets set to 0 despite not being open so the next time // the user ArrowDowns, the first item won't be focused. - if ( - !this.context.open && - event.currentTarget === this.#floatingFocusElement - ) { - return; - } + if (!context.open && event.currentTarget === floatingFocusElement) return; - if ( - this.#nested && - isCrossOrientationCloseKey(event.key, this.#orientation, this.#rtl) - ) { + if (nested && isCrossOrientationCloseKey(event.key, orientation, rtl)) { stopEvent(event); - this.context.onOpenChange(false, event, "list-navigation"); + context.onOpenChange(false, event, "list-navigation"); - if (isHTMLElement(this.context.domReference)) { - if (this.#virtual) { - this.#tree?.events.emit("virtualfocus", this.context.domReference); + if (isHTMLElement(context.domReference)) { + if (virtual) { + tree?.events.emit("virtualfocus", context.domReference); } else { - this.context.domReference.focus(); + context.domReference.focus(); } } return; } - const currentIndex = this.#index; - const minIndex = getMinIndex(this.#listRef, this.#disabledIndices); - const maxIndex = getMaxIndex(this.#listRef, this.#disabledIndices); + const currentIndex = index; + const minIndex = getMinIndex(listRef, disabledIndices); + const maxIndex = getMaxIndex(listRef, disabledIndices); - if (!this.#typeableComboboxReference) { + if (!typeableComboboxReference) { if (event.key === "Home") { stopEvent(event); - this.#index = minIndex; - this.#onNavigate(); + index = minIndex; + onNavigate(); } if (event.key === "End") { stopEvent(event); - this.#index = maxIndex; - this.#onNavigate(); + index = maxIndex; + onNavigate(); } } // Grid navigation. - if (this.#cols > 1) { + if (cols > 1) { const sizes = - this.#itemSizes || - Array.from({ length: this.#listRef.length }, () => ({ + itemSizes || + Array.from({ length: listRef.length }, () => ({ width: 1, height: 1, })); // To calculate movements on the grid, we use hypothetical cell indices // as if every item was 1x1, then convert back to real indices. - const cellMap = buildCellMap(sizes, this.#cols, this.#dense); + const cellMap = buildCellMap(sizes, cols, dense); const minGridIndex = cellMap.findIndex( (index) => - index != null && - !isDisabled(this.#listRef, index, this.#disabledIndices), + index != null && !isDisabled(listRef, index, disabledIndices), ); // last enabled index const maxGridIndex = cellMap.reduce( (foundIndex: number, index, cellIndex) => - index != null && - !isDisabled(this.#listRef, index, this.#disabledIndices) + index != null && !isDisabled(listRef, index, disabledIndices) ? cellIndex : foundIndex, -1, ); - const index = + const localIndex = cellMap[ getGridNavigatedIndex( cellMap.map((itemIndex) => - itemIndex != null ? this.#listRef[itemIndex] : null, + itemIndex != null ? listRef[itemIndex] : null, ), { event, - orientation: this.#orientation, - loop: this.#loop, - rtl: this.#rtl, - cols: this.#cols, + orientation: orientation, + loop, + rtl, + cols, // treat undefined (empty grid spaces) as disabled indices so we // don't end up in them disabledIndices: getCellIndices( [ - ...(this.#disabledIndices || - this.#listRef.map((_, index) => - isDisabled(this.#listRef, index) ? index : undefined, + ...(disabledIndices || + listRef.map((_, index) => + isDisabled(listRef, index) ? index : undefined, )), undefined, ], @@ -681,16 +618,16 @@ class ListNavigationState { minIndex: minGridIndex, maxIndex: maxGridIndex, prevIndex: getCellIndexOfCorner( - this.#index > maxIndex ? minIndex : this.#index, + index > maxIndex ? minIndex : index, sizes, cellMap, - this.#cols, + cols, // use a corner matching the edge closest to the direction // we're moving in so we don't end up in the same item. Prefer // top/left over bottom/right. event.key === ARROW_DOWN ? "bl" - : event.key === (this.#rtl ? ARROW_LEFT : ARROW_RIGHT) + : event.key === (rtl ? ARROW_LEFT : ARROW_RIGHT) ? "tr" : "tl", ), @@ -699,146 +636,139 @@ class ListNavigationState { ) ]; - if (index != null) { - this.#index = index; - this.#onNavigate(); + if (localIndex != null) { + index = localIndex; + onNavigate(); } - if (this.#orientation === "both") { + if (orientation === "both") { return; } } - if (isMainOrientationKey(event.key, this.#orientation)) { + if (isMainOrientationKey(event.key, orientation)) { stopEvent(event); // Reset the index if no item is focused. if ( - this.context.open && - !this.#virtual && + context.open && + !virtual && isElement(event.currentTarget) && activeElement(event.currentTarget.ownerDocument) === event.currentTarget ) { - this.#index = isMainOrientationToEndKey( - event.key, - this.#orientation, - this.#rtl, - ) + index = isMainOrientationToEndKey(event.key, orientation, rtl) ? minIndex : maxIndex; - this.#onNavigate(); + onNavigate(); return; } - if (isMainOrientationToEndKey(event.key, this.#orientation, this.#rtl)) { - if (this.#loop) { - this.#index = + if (isMainOrientationToEndKey(event.key, orientation, rtl)) { + if (loop) { + index = currentIndex >= maxIndex - ? this.#allowEscape && currentIndex !== this.#listRef.length + ? allowEscape && currentIndex !== listRef.length ? -1 : minIndex - : findNonDisabledIndex(this.#listRef, { + : findNonDisabledIndex(listRef, { startingIndex: currentIndex, - disabledIndices: this.#disabledIndices, + disabledIndices: disabledIndices, }); } else { - this.#index = Math.min( + index = Math.min( maxIndex, - findNonDisabledIndex(this.#listRef, { + findNonDisabledIndex(listRef, { startingIndex: currentIndex, - disabledIndices: this.#disabledIndices, + disabledIndices: disabledIndices, }), ); } } else { - if (this.#loop) { - this.#index = + if (loop) { + index = currentIndex <= minIndex - ? this.#allowEscape && currentIndex !== -1 - ? this.#listRef.length + ? allowEscape && currentIndex !== -1 + ? listRef.length : maxIndex - : findNonDisabledIndex(this.#listRef, { + : findNonDisabledIndex(listRef, { startingIndex: currentIndex, decrement: true, - disabledIndices: this.#disabledIndices, + disabledIndices: disabledIndices, }); } else { - this.#index = Math.max( + index = Math.max( minIndex, - findNonDisabledIndex(this.#listRef, { + findNonDisabledIndex(listRef, { startingIndex: currentIndex, decrement: true, - disabledIndices: this.#disabledIndices, + disabledIndices: disabledIndices, }), ); } } - if (isIndexOutOfBounds(this.#listRef, this.#index)) { - this.#index = -1; + if (isIndexOutOfBounds(listRef, index)) { + index = -1; } - this.#onNavigate(); + onNavigate(); } }; - #floatingOnPointerMove: PointerEventHandler = () => { - this.#isPointerModality = true; + const floatingOnPointerMove: PointerEventHandler = () => { + isPointerModality = true; }; - #floating: ElementProps["floating"] = $derived.by(() => ({ - "aria-orientation": - this.#orientation === "both" ? undefined : this.#orientation, - ...(!this.#typeableComboboxReference ? this.#ariaActiveDescendantProp : {}), - onkeydown: this.#commonOnKeyDown, - onpointermove: this.#floatingOnPointerMove, - })); - - #checkVirtualMouse = (event: MouseEvent) => { - if (this.#focusItemOnOpenProp === "auto" && isVirtualClick(event)) { - this.#focusItemOnOpen = true; + const floating: ElementProps["floating"] = $derived({ + "aria-orientation": orientation === "both" ? undefined : orientation, + ...(!typeableComboboxReference ? ariaActiveDescendantProp : {}), + onkeydown: commonOnKeyDown, + onpointermove: floatingOnPointerMove, + }); + + const checkVirtualMouse = (event: MouseEvent) => { + if (focusItemOnOpenProp === "auto" && isVirtualClick(event)) { + focusItemOnOpen = true; } }; - #checkVirtualPointer = (event: PointerEvent) => { + const checkVirtualPointer = (event: PointerEvent) => { // `pointerdown` fires first, reset the state then perform the checks. - this.#focusItemOnOpen = this.#focusItemOnOpenProp; - if (this.#focusItemOnOpenProp === "auto" && isVirtualPointerEvent(event)) { - this.#focusItemOnOpen = true; + focusItemOnOpen = focusItemOnOpenProp; + if (focusItemOnOpenProp === "auto" && isVirtualPointerEvent(event)) { + focusItemOnOpen = true; } }; - #referenceOnKeyDown = (event: KeyboardEvent) => { - this.#isPointerModality = false; - const isOpen = this.context.open; + const referenceOnKeyDown = (event: KeyboardEvent) => { + isPointerModality = false; + const isOpen = context.open; const isArrowKey = event.key.startsWith("Arrow"); const isHomeOrEndKey = ["Home", "End"].includes(event.key); const isMoveKey = isArrowKey || isHomeOrEndKey; const isCrossOpenKey = isCrossOrientationOpenKey( event.key, - this.#orientation, - this.#rtl, + orientation, + rtl, ); const isCrossCloseKey = isCrossOrientationCloseKey( event.key, - this.#orientation, - this.#rtl, + orientation, + rtl, ); - const isMainKey = isMainOrientationKey(event.key, this.#orientation); + const isMainKey = isMainOrientationKey(event.key, orientation); const isNavigationKey = - (this.#nested ? isCrossOpenKey : isMainKey) || + (nested ? isCrossOpenKey : isMainKey) || event.key === "Enter" || event.key.trim() === ""; - if (this.#virtual && isOpen) { - const rootNode = this.#tree?.nodes.find((node) => node.parentId == null); + if (virtual && isOpen) { + const rootNode = tree?.nodes.find((node) => node.parentId == null); const deepestNode = - this.#tree && rootNode - ? getDeepestNode(this.#tree.nodes, rootNode.id) - : null; + tree && rootNode ? getDeepestNode(tree.nodes, rootNode.id) : null; - if (isMoveKey && deepestNode && this.#virtualItemRef) { + if (isMoveKey && deepestNode && virtualItemRef) { const eventObject = new KeyboardEvent("keydown", { key: event.key, bubbles: true, @@ -851,13 +781,13 @@ class ListNavigationState { isCrossCloseKey && !isCurrentTarget ? deepestNode.context?.domReference : isCrossOpenKey - ? this.#listRef.find((item) => item?.id === this.#activeId) + ? listRef.find((item) => item?.id === activeId) : null; if (dispatchItem) { stopEvent(event); dispatchItem.dispatchEvent(eventObject); - this.#virtualId = undefined; + virtualId = undefined; } } @@ -874,26 +804,26 @@ class ListNavigationState { } } - return this.#commonOnKeyDown(event); + return commonOnKeyDown(event); } // If a floating element should not open on arrow key down, avoid // setting `activeIndex` while it's closed. - if (!isOpen && !this.#openOnArrowKeyDown && isArrowKey) return; + if (!isOpen && !openOnArrowKeyDown && isArrowKey) return; if (isNavigationKey) { - this.#key = this.#nested && isMainKey ? null : event.key; + key = nested && isMainKey ? null : event.key; } - if (this.#nested) { + if (nested) { if (isCrossOpenKey) { stopEvent(event); if (isOpen) { - this.#index = getMinIndex(this.#listRef, this.#disabledIndices); - this.#onNavigate(); + index = getMinIndex(listRef, disabledIndices); + onNavigate(); } else { - this.context.onOpenChange(true, event, "list-navigation"); + context.onOpenChange(true, event, "list-navigation"); } } @@ -901,55 +831,55 @@ class ListNavigationState { } if (isMainKey) { - if (this.#selectedIndex != null) { - this.#index = this.#selectedIndex; + if (selectedIndex != null) { + index = selectedIndex; } stopEvent(event); - if (!isOpen && this.#openOnArrowKeyDown) { - this.context.onOpenChange(true, event, "list-navigation"); + if (!isOpen && openOnArrowKeyDown) { + context.onOpenChange(true, event, "list-navigation"); } else { - this.#commonOnKeyDown(event); + commonOnKeyDown(event); } if (isOpen) { - this.#onNavigate(); + onNavigate(); } } }; - #referenceOnFocus = () => { - if (this.context.open && !this.#virtual) { - this.#index = -1; - this.#onNavigate(); + const referenceOnFocus = () => { + if (context.open && !virtual) { + index = -1; + onNavigate(); } }; - #reference: ElementProps["reference"] = $derived.by(() => ({ - ...this.#ariaActiveDescendantProp, - onkeydown: this.#referenceOnKeyDown, - onfocus: this.#referenceOnFocus, - onpointerdown: this.#checkVirtualPointer, - onpointerenter: this.#checkVirtualPointer, - onmousedown: this.#checkVirtualMouse, - onclick: this.#checkVirtualMouse, - })); - - get floating() { - if (!this.#enabled) return {}; - return this.#floating; - } - - get item() { - if (!this.#enabled) return {}; - return this.#item; - } + const reference: ElementProps["reference"] = $derived({ + ...ariaActiveDescendantProp, + onkeydown: referenceOnKeyDown, + onfocus: referenceOnFocus, + onpointerdown: checkVirtualPointer, + onpointerenter: checkVirtualPointer, + onmousedown: checkVirtualMouse, + onclick: checkVirtualMouse, + }); - get reference() { - if (!this.#enabled) return {}; - return this.#reference; - } + return { + get floating() { + if (!enabled) return {}; + return floating; + }, + get item() { + if (!enabled) return {}; + return item; + }, + get reference() { + if (!enabled) return {}; + return reference; + }, + }; } function doSwitch( @@ -1011,12 +941,5 @@ function isCrossOrientationCloseKey( return doSwitch(orientation, vertical, horizontal); } -function useListNavigation( - context: FloatingContext | FloatingRootContext, - opts: UseListNavigationOptions, -) { - return new ListNavigationState(context, opts); -} - export { useListNavigation }; export type { UseListNavigationOptions }; diff --git a/packages/floating-ui-svelte/src/internal/extract.ts b/packages/floating-ui-svelte/src/internal/extract.ts index bcb34b8a..037ed550 100644 --- a/packages/floating-ui-svelte/src/internal/extract.ts +++ b/packages/floating-ui-svelte/src/internal/extract.ts @@ -16,13 +16,16 @@ function extract( ): D extends undefined ? T : Exclude { if (isFunction(value)) { const getter = value as Getter; - const result = getter(); - // biome-ignore lint/suspicious/noExplicitAny: - if (result !== undefined) return result as any; - // biome-ignore lint/suspicious/noExplicitAny: - if (defaultValue !== undefined) return defaultValue as any; + + const res = + getter() !== undefined + ? getter() + : defaultValue !== undefined + ? defaultValue + : getter(); + // biome-ignore lint/suspicious/noExplicitAny: - return result as any; + return res as any; } // biome-ignore lint/suspicious/noExplicitAny: diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts index 04a9f306..7d7077ec 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts @@ -75,7 +75,9 @@ it("resets index to -1 upon close", async () => { await userEvent.keyboard("a"); expect(screen.getByTestId("floating")).toBeInTheDocument(); - expect(screen.getByTestId("active-index").textContent).toBe(""); + await waitFor(() => + expect(screen.getByTestId("active-index").textContent).toBe(""), + ); await userEvent.keyboard(testKbd.ARROW_DOWN); await userEvent.keyboard(testKbd.ARROW_DOWN); @@ -1033,10 +1035,10 @@ it("handles scheduled list population", async () => { await waitFor(() => expect(screen.getAllByRole("option")[0]).toHaveFocus()); }); -it.only("async selectedIndex", async () => { +it("async selectedIndex", async () => { render(Select); - await fireEvent.click(screen.getByRole("button")); + await userEvent.click(screen.getByRole("button")); await act(async () => {}); await waitFor(() => expect(screen.getAllByRole("option")[2]).toHaveFocus()); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte index 13404e4d..c6393f7a 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte @@ -25,10 +25,10 @@ useListNavigation(f.context, { listRef: () => listRef, activeIndex: () => activeIndex, - onNavigate(index) { + onNavigate: (index) => { activeIndex = index; }, - virtual: true, + virtual: () => true, loop: true, }), ]); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte index 5d339889..aa49c145 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte @@ -13,7 +13,7 @@ index: number; } = $props(); - let index = $state(-1); + let index = $state(propIndex); $effect(() => { index = propIndex; @@ -29,8 +29,6 @@ } } ); - - $effect(() => {});
    import { - useClick, + useDismiss, useFloating, useInteractions, useListNavigation, } from "../../../../src/index.js"; - import FloatingFocusManager from "../../../../src/components/floating-focus-manager/floating-focus-manager.svelte"; - import FloatingList from "../../../../src/components/floating-list/floating-list.svelte"; - import SelectOption from "../../../unit/hooks/wrapper-components/use-list-navigation/select-option.svelte"; + import { styleObjectToString } from "../../../../src/internal/style-object-to-string.js"; - let activeIndex = $state(null); - let selectedIndex = $state(2); let open = $state(false); + let inputValue = $state(""); + let activeIndex = $state(null); + + let listRef = $state>([]); + + const data = ["a", "ab", "abc", "abcd"]; const f = useFloating({ open: () => open, onOpenChange: (v) => (open = v), }); - let elements = $state>([]); + const ints = useInteractions([ + useDismiss(f.context), + useListNavigation(f.context, { + listRef: () => listRef, + activeIndex: () => activeIndex, + onNavigate: (index) => { + activeIndex = index; + }, + virtual: true, + loop: true, + }), + ]); - const click = useClick(f.context); - const listNav = useListNavigation(f.context, { - listRef: () => elements, - activeIndex: () => activeIndex, - selectedIndex: () => selectedIndex, - onNavigate: (idx) => (activeIndex = idx), + $effect(() => { + if (inputValue) { + activeIndex = null; + open = true; + } else { + open = false; + } }); - const ints = useInteractions([listNav, click]); - const options = [ - "core", - "dom", - "react", - "react-dom", - "vue", - "react-native", - ]; + const items = $derived( + data.filter((item) => + item.toLowerCase().startsWith(inputValue.toLowerCase()) + ) + ); - + {#if open} - -
    - - {#each options as option (option)} - - {/each} - -
    -
    +
    +
      + {#each items as item, index (item)} +
    • { + inputValue = item; + open = false; + f.domReference?.focus(); + }, + })}> + {item} +
    • + {/each} +
    +
    {/if} +
    {activeIndex}
    From 747ebc57e48a27e114038be1935ef9a9a926f8b8 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 20 Jan 2025 21:51:40 -0500 Subject: [PATCH 54/87] whew --- .../src/hooks/use-floating.svelte.ts | 4 +- .../src/hooks/use-list-navigation.svelte.ts | 44 ++-- .../unit/hooks/use-list-navigation.test.ts | 25 +- .../components/emoji-picker/main.svelte | 230 +++++++++++++++++- .../components/emoji-picker/option.svelte | 7 +- .../visual/routes/emoji-picker/+page.svelte | 5 + 6 files changed, 289 insertions(+), 26 deletions(-) create mode 100644 packages/floating-ui-svelte/test/visual/routes/emoji-picker/+page.svelte diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index 05857a29..a2e2745e 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -342,10 +342,10 @@ class FloatingState { } get reference() { - return this.#position.referenceEl as RT | null; + return this.#position.referenceEl as Element | null; } - set reference(node: RT | null) { + set reference(node: Element | null) { if (isElement(node) || node === null) { this.options.reference.current = node; } diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index 4fb94e8e..67b7847c 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -216,6 +216,10 @@ function useListNavigation( isTypeableCombobox(context.domReference), ); + $effect(() => { + console.log("listref changed", $state.snapshot(listRef)); + }); + const hasActiveIndex = $derived(activeIndex != null); const ariaActiveDescendantProp = $derived.by(() => { @@ -297,7 +301,7 @@ function useListNavigation( () => activeIndex, () => selectedIndex, () => nested, - () => listRef, + () => $state.snapshot(listRef), () => orientation, () => rtl, () => disabledIndices, @@ -550,8 +554,9 @@ function useListNavigation( } const currentIndex = index; - const minIndex = getMinIndex(listRef, disabledIndices); - const maxIndex = getMaxIndex(listRef, disabledIndices); + const filteredListRef = listRef.filter((item) => item !== null); + const minIndex = getMinIndex(filteredListRef, disabledIndices); + const maxIndex = getMaxIndex(filteredListRef, disabledIndices); if (!typeableComboboxReference) { if (event.key === "Home") { @@ -571,7 +576,7 @@ function useListNavigation( if (cols > 1) { const sizes = itemSizes || - Array.from({ length: listRef.length }, () => ({ + Array.from({ length: filteredListRef.length }, () => ({ width: 1, height: 1, })); @@ -580,12 +585,12 @@ function useListNavigation( const cellMap = buildCellMap(sizes, cols, dense); const minGridIndex = cellMap.findIndex( (index) => - index != null && !isDisabled(listRef, index, disabledIndices), + index != null && !isDisabled(filteredListRef, index, disabledIndices), ); // last enabled index const maxGridIndex = cellMap.reduce( (foundIndex: number, index, cellIndex) => - index != null && !isDisabled(listRef, index, disabledIndices) + index != null && !isDisabled(filteredListRef, index, disabledIndices) ? cellIndex : foundIndex, -1, @@ -595,11 +600,11 @@ function useListNavigation( cellMap[ getGridNavigatedIndex( cellMap.map((itemIndex) => - itemIndex != null ? listRef[itemIndex] : null, + itemIndex != null ? filteredListRef[itemIndex] : null, ), { event, - orientation: orientation, + orientation, loop, rtl, cols, @@ -608,8 +613,8 @@ function useListNavigation( disabledIndices: getCellIndices( [ ...(disabledIndices || - listRef.map((_, index) => - isDisabled(listRef, index) ? index : undefined, + filteredListRef.map((_, index) => + isDisabled(filteredListRef, index) ? index : undefined, )), undefined, ], @@ -637,13 +642,12 @@ function useListNavigation( ]; if (localIndex != null) { + console.log("localIndex", localIndex); index = localIndex; onNavigate(); } - if (orientation === "both") { - return; - } + if (orientation === "both") return; } if (isMainOrientationKey(event.key, orientation)) { @@ -667,17 +671,17 @@ function useListNavigation( if (loop) { index = currentIndex >= maxIndex - ? allowEscape && currentIndex !== listRef.length + ? allowEscape && currentIndex !== filteredListRef.length ? -1 : minIndex - : findNonDisabledIndex(listRef, { + : findNonDisabledIndex(filteredListRef, { startingIndex: currentIndex, disabledIndices: disabledIndices, }); } else { index = Math.min( maxIndex, - findNonDisabledIndex(listRef, { + findNonDisabledIndex(filteredListRef, { startingIndex: currentIndex, disabledIndices: disabledIndices, }), @@ -688,9 +692,9 @@ function useListNavigation( index = currentIndex <= minIndex ? allowEscape && currentIndex !== -1 - ? listRef.length + ? filteredListRef.length : maxIndex - : findNonDisabledIndex(listRef, { + : findNonDisabledIndex(filteredListRef, { startingIndex: currentIndex, decrement: true, disabledIndices: disabledIndices, @@ -698,7 +702,7 @@ function useListNavigation( } else { index = Math.max( minIndex, - findNonDisabledIndex(listRef, { + findNonDisabledIndex(filteredListRef, { startingIndex: currentIndex, decrement: true, disabledIndices: disabledIndices, @@ -707,7 +711,7 @@ function useListNavigation( } } - if (isIndexOutOfBounds(listRef, index)) { + if (isIndexOutOfBounds(filteredListRef, index)) { index = -1; } diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts index 7d7077ec..1d5d31a2 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts @@ -14,6 +14,7 @@ import Grid from "../../visual/components/grid/main.svelte"; import ComplexGrid from "../../visual/components/complex-grid/main.svelte"; import Scheduled from "./wrapper-components/use-list-navigation/scheduled.svelte"; import Select from "./wrapper-components/use-list-navigation/select.svelte"; +import EmojiPicker from "../../visual/components/emoji-picker/main.svelte"; it("opens on ArrowDown and focuses first item", async () => { render(Main); @@ -1046,7 +1047,29 @@ it("async selectedIndex", async () => { await waitFor(() => expect(screen.getAllByRole("option")[3]).toHaveFocus()); }); -it.todo("grid navigation with changing list items"); +it("grid navigation with changing list items", async () => { + render(EmojiPicker); + + await fireEvent.click(screen.getByRole("button")); + + await act(async () => {}); + + await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus()); + + await userEvent.keyboard("appl"); + await sleep(200); + await userEvent.keyboard(testKbd.ARROW_DOWN); + + await waitFor(() => + expect(screen.getByLabelText("apple")).toHaveAttribute("data-active"), + ); + + await userEvent.keyboard(testKbd.ARROW_DOWN); + + await waitFor(() => + expect(screen.getByLabelText("apple")).toHaveAttribute("data-active"), + ); +}); it.todo("grid navigation with disabled list items"); diff --git a/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte b/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte index e5324275..342a7195 100644 --- a/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte @@ -1,5 +1,65 @@ + + + +

    Emoji Picker

    +
    +
    + +
    + {#if selectedEmoji} + + emoji === selectedEmoji + )?.name}> + {selectedEmoji} + {" "} + selected + + {/if} + {#if open} + + +
    + + + Emoji Picker + + + {#if filteredEmojis.length === 0} +

    + No results. +

    + {:else} +
    + {#each filteredEmojis as { name, emoji }, index (name)} + + {/each} +
    + {/if} +
    +
    +
    + {/if} +
    +
    diff --git a/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte b/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte index ef279abb..84d97df1 100644 --- a/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte +++ b/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte @@ -9,13 +9,15 @@ children, active, selected, - ref = $bindable(null), + ref = $bindable(), + index, ...rest }: HTMLButtonAttributes & { name: string; active: boolean; selected: boolean; - } & Partial> = $props(); + index: number; + } & Partial> = $props(); diff --git a/packages/floating-ui-svelte/test/visual/routes/emoji-picker/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/emoji-picker/+page.svelte new file mode 100644 index 00000000..19149048 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/emoji-picker/+page.svelte @@ -0,0 +1,5 @@ + + +
    From 43b8042bf3f63f5261bc70fa7c715f90f1b5cfb5 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 21 Jan 2025 19:37:03 -0500 Subject: [PATCH 55/87] did someone ask for housekeeping --- .../src/hooks/use-click.svelte.ts | 145 ++-- .../src/hooks/use-client-point.svelte.ts | 225 +++---- .../src/hooks/use-dismiss.svelte.ts | 426 ++++++------ .../src/hooks/use-focus.svelte.ts | 197 +++--- .../src/hooks/use-hover.svelte.ts | 630 +++++++++--------- .../src/hooks/use-list-navigation.svelte.ts | 11 +- .../src/hooks/use-role.svelte.ts | 110 ++- .../src/hooks/use-transition.svelte.ts | 284 ++++---- 8 files changed, 945 insertions(+), 1083 deletions(-) diff --git a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts index 61fec7a8..d27dd6a8 100644 --- a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts @@ -66,140 +66,131 @@ const pointerTypes = ["mouse", "pen", "touch"] as const; type PointerType = (typeof pointerTypes)[number]; -class ClickInteraction implements ElementProps { - #enabled = $derived.by(() => extract(this.options?.enabled, true)); - #eventOption = $derived.by(() => extract(this.options?.event, "click")); - #toggle = $derived.by(() => extract(this.options?.toggle, true)); - #ignoreMouse = $derived.by(() => extract(this.options?.ignoreMouse, false)); - #stickIfOpen = $derived.by(() => extract(this.options?.stickIfOpen, true)); - #keyboardHandlers = $derived.by(() => - extract(this.options?.keyboardHandlers, true), - ); - #pointerType: PointerType | undefined = undefined; - #didKeyDown = false; - - constructor( - private readonly context: FloatingContext, - private readonly options: UseClickOptions = {}, - ) {} - - #onpointerdown = (event: PointerEvent) => { +function useClick( + context: FloatingContext, + opts: UseClickOptions = {}, +): ElementProps { + const enabled = $derived(extract(opts.enabled, true)); + const eventOption = $derived(extract(opts.event, "click")); + const toggle = $derived(extract(opts.toggle, true)); + const ignoreMouse = $derived(extract(opts.ignoreMouse, false)); + const stickIfOpen = $derived(extract(opts.stickIfOpen, true)); + const keyboardHandlers = $derived(extract(opts.keyboardHandlers, true)); + let pointerType: PointerType | undefined = undefined; + let didKeyDown = false; + + function onpointerdown(event: PointerEvent) { if (!isPointerType(event.pointerType)) return; - this.#pointerType = event.pointerType; - }; + pointerType = event.pointerType; + } - #onmousedown = (event: MouseEvent) => { + function onmousedown(event: MouseEvent) { // Ignore all buttons except for the "main" button. // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button if (event.button !== 0) return; - if (this.#eventOption === "click") return; - if (isMouseLikePointerType(this.#pointerType, true) && this.#ignoreMouse) { + if (eventOption === "click") return; + if (isMouseLikePointerType(pointerType, true) && ignoreMouse) { return; } if ( - this.context.open && - this.#toggle && - (this.context.data.openEvent && this.#stickIfOpen - ? this.context.data.openEvent.type === "mousedown" + context.open && + toggle && + (context.data.openEvent && stickIfOpen + ? context.data.openEvent.type === "mousedown" : true) ) { - this.context.onOpenChange(false, event, "click"); + context.onOpenChange(false, event, "click"); } else { // Prevent stealing focus from the floating element event.preventDefault(); - this.context.onOpenChange(true, event, "click"); + context.onOpenChange(true, event, "click"); } - }; + } - #onclick = (event: MouseEvent) => { - if (this.#eventOption === "mousedown" && this.#pointerType) { - this.#pointerType = undefined; + function onclick(event: MouseEvent) { + if (eventOption === "mousedown" && pointerType) { + pointerType = undefined; return; } - if (isMouseLikePointerType(this.#pointerType, true) && this.#ignoreMouse) { + if (isMouseLikePointerType(pointerType, true) && ignoreMouse) { return; } if ( - this.context.open && - this.#toggle && - (this.context.data.openEvent && this.#stickIfOpen - ? this.context.data.openEvent.type === "click" + context.open && + toggle && + (context.data.openEvent && stickIfOpen + ? context.data.openEvent.type === "click" : true) ) { - this.context.onOpenChange(false, event, "click"); + context.onOpenChange(false, event, "click"); } else { - this.context.onOpenChange(true, event, "click"); + context.onOpenChange(true, event, "click"); } - }; + } - #onkeydown = (event: KeyboardEvent) => { - this.#pointerType = undefined; + function onkeydown(event: KeyboardEvent) { + pointerType = undefined; - if ( - event.defaultPrevented || - !this.#keyboardHandlers || - isButtonTarget(event) - ) { + if (event.defaultPrevented || !keyboardHandlers || isButtonTarget(event)) { return; } - if (event.key === " " && !isSpaceIgnored(this.context.domReference)) { + if (event.key === " " && !isSpaceIgnored(context.domReference)) { // Prevent scrolling event.preventDefault(); - this.#didKeyDown = true; + didKeyDown = true; } if (event.key === "Enter") { - if (this.context.open && this.#toggle) { - this.context.onOpenChange(false, event, "click"); + if (context.open && toggle) { + context.onOpenChange(false, event, "click"); } else { - this.context.onOpenChange(true, event, "click"); + context.onOpenChange(true, event, "click"); } } - }; + } - #onkeyup = (event: KeyboardEvent) => { + function onkeyup(event: KeyboardEvent) { if ( event.defaultPrevented || - !this.#keyboardHandlers || + !keyboardHandlers || isButtonTarget(event) || - isSpaceIgnored(this.context.domReference) + isSpaceIgnored(context.domReference) ) { return; } - if (event.key === " " && this.#didKeyDown) { - this.#didKeyDown = false; - if (this.context.open && this.#toggle) { - this.context.onOpenChange(false, event, "click"); + if (event.key === " " && didKeyDown) { + didKeyDown = false; + if (context.open && toggle) { + context.onOpenChange(false, event, "click"); } else { - this.context.onOpenChange(true, event, "click"); + context.onOpenChange(true, event, "click"); } } - }; - - get reference() { - return this.#reference; } - #reference = $derived.by(() => { - if (!this.#enabled) return {}; + const reference = $derived.by(() => { + if (!enabled) return {}; return { - onpointerdown: this.#onpointerdown, - onmousedown: this.#onmousedown, - onclick: this.#onclick, - onkeydown: this.#onkeydown, - onkeyup: this.#onkeyup, + onpointerdown: onpointerdown, + onmousedown: onmousedown, + onclick: onclick, + onkeydown: onkeydown, + onkeyup: onkeyup, }; }); -} -function useClick(context: FloatingContext, options: UseClickOptions = {}) { - return new ClickInteraction(context, options); + return { + get reference() { + if (!enabled) return {}; + return reference; + }, + }; } export type { UseClickOptions }; -export { useClick, ClickInteraction }; +export { useClick }; diff --git a/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts index bcd450d0..9b303bde 100644 --- a/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts @@ -114,182 +114,161 @@ interface UseClientPointOptions { y?: MaybeGetter; } -class ClientPointInteraction implements ElementProps { - #enabled = $derived.by(() => extract(this.options?.enabled, true)); - #axis = $derived.by(() => extract(this.options?.axis, "both")); - #x = $derived.by(() => extract(this.options?.x, null)); - #y = $derived.by(() => extract(this.options?.y, null)); - #initial = false; - #cleanupListener: (() => void) | null = null; - - #pointerType = $state(); - #listenerDeps = $state.raw([]); +function useClientPoint( + context: FloatingContext, + opts: UseClientPointOptions = {}, +): ElementProps { + const enabled = $derived(extract(opts.enabled, true)); + const axis = $derived(extract(opts.axis, "both")); + const x = $derived(extract(opts.x, null)); + const y = $derived(extract(opts.y, null)); + let initial = false; + let cleanupListener: (() => void) | null = null; + let pointerType = $state(); + let listenerDeps = $state.raw([]); // If the pointer is a mouse-like pointer, we want to continue following the // mouse even if the floating element is transitioning out. On touch // devices, this is undesirable because the floating element will move to // the dismissal touch point. - #openCheck = $derived.by(() => - isMouseLikePointerType(this.#pointerType) - ? this.context.floating - : this.context.open, + const openCheck = $derived( + isMouseLikePointerType(pointerType) ? context.floating : context.open, ); - constructor( - private readonly context: FloatingContext, - private readonly options: UseClientPointOptions = {}, - ) { - watch( - () => this.#listenerDeps, - () => { - return this.#addListener(); - }, - ); - - watch([() => this.#enabled, () => this.context.floating], () => { - if (this.#enabled && !this.context.floating) { - this.#initial = false; - } - }); - - watch([() => this.#enabled, () => this.context.floating], () => { - if (!this.#enabled && this.context.open) { - this.#initial = true; - } - }); - - watch.pre([() => this.#enabled, () => this.#x, () => this.#y], () => { - if (this.#enabled && (this.#x != null || this.#y != null)) { - this.#initial = false; - this.#setReference(this.#x, this.#y); - } - }); - - watch([() => this.#enabled, () => this.context.open], () => { - if (this.#enabled && this.context.open) { - this.#listenerDeps = []; - } - }); - - watch( - () => this.#openCheck, - () => { - if (!this.#openCheck && this.#cleanupListener) { - this.#cleanupListener(); - } - }, - ); - } - - #setReference = (x: number | null, y: number | null) => { - if (this.#initial) return; + function setReference(x: number | null, y: number | null) { + if (initial) return; // Prevent setting if the open event was not a mouse-like one // (e.g. focus to open, then hover over the reference element). // Only apply if the event exists. - if ( - this.context.data.openEvent && - !isMouseBasedEvent(this.context.data.openEvent) - ) { + if (context.data.openEvent && !isMouseBasedEvent(context.data.openEvent)) { return; } - this.context.setPositionReference( - createVirtualElement(this.context.domReference, { + context.setPositionReference( + createVirtualElement(context.domReference, { x, y, - axis: this.#axis, - data: this.context.data, - pointerType: this.#pointerType, + axis: axis, + data: context.data, + pointerType: pointerType, }), ); - }; + } - #handleReferenceEnterOrMove = (event: MouseEvent) => { - if (this.#x != null || this.#y != null) return; + function handleReferenceEnterOrMove(event: MouseEvent) { + if (x != null || y != null) return; - if (!this.context.open) { - this.#setReference(event.clientX, event.clientY); - } else if (!this.#cleanupListener) { + if (!context.open) { + setReference(event.clientX, event.clientY); + } else if (!cleanupListener) { // If there's no cleanup, there's no listener, but we want to ensure // we add the listener if the cursor landed on the floating element and // then back on the reference (i.e. it's interactive). - this.#listenerDeps = []; + listenerDeps = []; } - }; + } - #addListener() { - if ( - !this.#openCheck || - !this.#enabled || - this.#x != null || - this.#y != null - ) { + function addListener() { + if (!openCheck || !enabled || x != null || y != null) { // Clear existing listener when conditions change - if (this.#cleanupListener) { - this.#cleanupListener(); - this.#cleanupListener = null; + if (cleanupListener) { + cleanupListener(); + cleanupListener = null; } return; } // Clear existing listener before adding new one - if (this.#cleanupListener) { - this.#cleanupListener(); - this.#cleanupListener = null; + if (cleanupListener) { + cleanupListener(); + cleanupListener = null; } - const win = getWindow(this.context.floating); + const win = getWindow(context.floating); const handleMouseMove = (event: MouseEvent) => { const target = getTarget(event) as Element | null; - if (!contains(this.context.floating, target)) { - this.#setReference(event.clientX, event.clientY); + if (!contains(context.floating, target)) { + setReference(event.clientX, event.clientY); } else { win.removeEventListener("mousemove", handleMouseMove); - this.#cleanupListener = null; + cleanupListener = null; } }; - if ( - !this.context.data.openEvent || - isMouseBasedEvent(this.context.data.openEvent) - ) { + if (!context.data.openEvent || isMouseBasedEvent(context.data.openEvent)) { win.addEventListener("mousemove", handleMouseMove); const cleanup = () => { win.removeEventListener("mousemove", handleMouseMove); - this.#cleanupListener = null; + cleanupListener = null; }; - this.#cleanupListener = cleanup; + cleanupListener = cleanup; return cleanup; } - this.context.setPositionReference(this.context.domReference); + context.setPositionReference(context.domReference); } - #setPointerType = (event: PointerEvent) => { - this.#pointerType = event.pointerType; - }; + function setPointerType(event: PointerEvent) { + pointerType = event.pointerType; + } - #reference = $derived.by(() => ({ - onpointerdown: this.#setPointerType, - onpointerenter: this.#setPointerType, - onmousemove: this.#handleReferenceEnterOrMove, - onmouseenter: this.#handleReferenceEnterOrMove, - })); + watch( + () => listenerDeps, + () => { + return addListener(); + }, + ); - get reference() { - if (!this.#enabled) return {}; - return this.#reference; - } -} + watch([() => enabled, () => context.floating], () => { + if (enabled && !context.floating) { + initial = false; + } + }); -function useClientPoint( - context: FloatingContext, - options: UseClientPointOptions = {}, -) { - return new ClientPointInteraction(context, options); + watch([() => enabled, () => context.floating], () => { + if (!enabled && context.open) { + initial = true; + } + }); + + watch.pre([() => enabled, () => x, () => y], () => { + if (enabled && (x != null || y != null)) { + initial = false; + setReference(x, y); + } + }); + + watch([() => enabled, () => context.open], () => { + if (enabled && context.open) { + listenerDeps = []; + } + }); + + watch( + () => openCheck, + () => { + if (!openCheck && cleanupListener) { + cleanupListener(); + } + }, + ); + + const reference = $derived({ + onpointerdown: setPointerType, + onpointerenter: setPointerType, + onmousemove: handleReferenceEnterOrMove, + onmouseenter: handleReferenceEnterOrMove, + }); + + return { + get reference() { + if (!enabled) return {}; + return reference; + }, + }; } export type { UseClientPointOptions }; -export { useClientPoint, ClientPointInteraction }; +export { useClientPoint }; diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index 9e7e907f..469bc5fd 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -121,177 +121,52 @@ interface UseDismissOptions { >; } -class DismissInteraction implements ElementProps { - #enabled = $derived.by(() => extract(this.options.enabled, true)); - #escapeKey = $derived.by(() => extract(this.options.escapeKey, true)); - #unstable_outsidePress = $derived.by(() => this.options.outsidePress ?? true); - #outsidePressEvent = $derived.by(() => - extract(this.options.outsidePressEvent, "pointerdown"), +function useDismiss( + context: FloatingContext, + opts: UseDismissOptions = {}, +): ElementProps { + const enabled = $derived(extract(opts.enabled, true)); + const escapeKey = $derived(extract(opts.escapeKey, true)); + const unstable_outsidePress = $derived(opts.outsidePress ?? true); + const outsidePressEvent = $derived( + extract(opts.outsidePressEvent, "pointerdown"), ); - #referencePress = $derived.by(() => - extract(this.options.referencePress, false), + const referencePress = $derived(extract(opts.referencePress, false)); + const referencePressEvent = $derived( + extract(opts.referencePressEvent, "pointerdown"), ); - #referencePressEvent = $derived.by(() => - extract(this.options.referencePressEvent, "pointerdown"), - ); - #ancestorScroll = $derived.by(() => - extract(this.options.ancestorScroll, false), - ); - #bubbles = $derived.by(() => extract(this.options.bubbles)); - #capture = $derived.by(() => extract(this.options.capture)); - #outsidePressFn = $derived.by(() => - typeof this.#unstable_outsidePress === "function" - ? this.#unstable_outsidePress + const ancestorScroll = $derived(extract(opts.ancestorScroll, false)); + const bubbles = $derived(extract(opts.bubbles)); + const capture = $derived(extract(opts.capture)); + const outsidePressFn = $derived( + typeof unstable_outsidePress === "function" + ? unstable_outsidePress : () => false, ); - #outsidePress = $derived.by(() => - typeof this.#unstable_outsidePress === "function" - ? this.#outsidePressFn - : this.#unstable_outsidePress, + const outsidePress = $derived( + typeof unstable_outsidePress === "function" + ? outsidePressFn + : unstable_outsidePress, ); - #bubbleOptions = $derived.by(() => normalizeProp(this.#bubbles)); - #captureOptions = $derived.by(() => normalizeProp(this.#capture)); - #endedOrStartedInside = false; - #isComposing = false; - #tree: FloatingTreeType | null; - #insideTree = false; - - constructor( - private readonly context: FloatingContext, - private readonly options: UseDismissOptions = {}, - ) { - this.#tree = useFloatingTree(); - - $effect(() => { - if (!this.context.open || !this.#enabled) return; - untrack(() => { - this.context.data.__escapeKeyBubbles = this.#bubbleOptions.escapeKey; - this.context.data.__outsidePressBubbles = - this.#bubbleOptions.outsidePress; - }); - - let compositionTimeout = -1; - - const onScroll = (event: Event) => { - this.context.onOpenChange(false, event, "ancestor-scroll"); - }; - - const handleCompositionStart = () => { - window.clearTimeout(compositionTimeout); - this.#isComposing = true; - }; - - const handleCompositionEnd = () => { - // Safari fires `compositionend` before `keydown`, so we need to wait - // until the next tick to set `isComposing` to `false`. - // https://bugs.webkit.org/show_bug.cgi?id=165004 - compositionTimeout = window.setTimeout( - () => { - this.#isComposing = false; - }, - // 0ms or 1ms don't work in Safari. 5ms appears to consistently work. - // Only apply to WebKit for the test to remain 0ms. - isWebKit() ? 5 : 0, - ); - }; - - const doc = getDocument(this.context.floating); - - const listenersToRemove: Array<() => void> = []; - - if (this.#escapeKey) { - listenersToRemove.push( - on( - doc, - "keydown", - this.#captureOptions.escapeKey - ? this.#closeOnEscapeKeyDownCapture - : this.#closeOnEscapeKeyDown, - { capture: this.#captureOptions.escapeKey }, - ), - on(doc, "compositionstart", handleCompositionStart), - on(doc, "compositionend", handleCompositionEnd), - ); - } - - if (this.#outsidePress) { - listenersToRemove.push( - on( - doc, - this.#outsidePressEvent, - this.#captureOptions.outsidePress - ? this.#closeOnPressOutsideCapture - : this.#closeOnPressOutside, - { capture: this.#captureOptions.outsidePress }, - ), - ); - } - - let ancestors: (Element | Window | VisualViewport)[] = []; - - if (this.#ancestorScroll) { - if (isElement(this.context.domReference)) { - ancestors = getOverflowAncestors(this.context.domReference); - } - - if (isElement(this.context.floating)) { - ancestors = ancestors.concat( - getOverflowAncestors(this.context.floating), - ); - } - - if ( - !isElement(this.context.reference) && - this.context.reference && - this.context.reference.contextElement - ) { - ancestors = ancestors.concat( - getOverflowAncestors(this.context.reference.contextElement), - ); - } - } - - // Ignore the visual viewport for scrolling dismissal (allow pinch-zoom) - ancestors = ancestors.filter( - (ancestor) => ancestor !== doc.defaultView?.visualViewport, - ); - - for (const ancestor of ancestors) { - listenersToRemove.push( - on(ancestor, "scroll", onScroll, { passive: true }), - ); - } - - return () => { - for (const removeListener of listenersToRemove) { - removeListener(); - } - window.clearTimeout(compositionTimeout); - }; - }); - - watch([() => this.#outsidePress, () => this.#outsidePressEvent], () => { - this.#insideTree = false; - }); - } - - #closeOnEscapeKeyDown = (event: KeyboardEvent) => { - if ( - !this.context.open || - !this.#enabled || - !this.#escapeKey || - event.key !== "Escape" - ) + const bubbleOptions = $derived(normalizeProp(bubbles)); + const captureOptions = $derived(normalizeProp(capture)); + let endedOrStartedInside = false; + let isComposing = false; + const tree = useFloatingTree(); + let insideTree = false; + + function closeOnEscapeKeyDown(event: KeyboardEvent) { + if (!context.open || !enabled || !escapeKey || event.key !== "Escape") return; // Wait until IME is settled. Pressing `Escape` while composing should // close the compose menu, but not the floating element. - if (this.#isComposing) return; + if (isComposing) return; - const nodeId = this.context.data.floatingContext?.nodeId; - const children = this.#tree ? getChildren(this.#tree.nodes, nodeId) : []; + const nodeId = context.data.floatingContext?.nodeId; + const children = tree ? getChildren(tree.nodes, nodeId) : []; - if (!this.#bubbleOptions.escapeKey) { + if (!bubbleOptions.escapeKey) { event.stopPropagation(); if (children.length > 0) { @@ -304,44 +179,41 @@ class DismissInteraction implements ElementProps { } } - this.context.onOpenChange(false, event, "escape-key"); - }; + context.onOpenChange(false, event, "escape-key"); + } - #closeOnEscapeKeyDownCapture = (event: KeyboardEvent) => { + function closeOnEscapeKeyDownCapture(event: KeyboardEvent) { const callback = () => { - this.#closeOnEscapeKeyDown(event); + closeOnEscapeKeyDown(event); getTarget(event)?.removeEventListener("keydown", callback); }; getTarget(event)?.addEventListener("keydown", callback); - }; + } - #closeOnPressOutside = (event: MouseEvent) => { - const localInsideTree = this.#insideTree; - this.#insideTree = false; + function closeOnPressOutside(event: MouseEvent) { + const localInsideTree = insideTree; + insideTree = false; // When click outside is lazy (`click` event), handle dragging. // Don't close if: // - The click started inside the floating element. // - The click ended inside the floating element. - const localEndedOrStartedInside = this.#endedOrStartedInside; - this.#endedOrStartedInside = false; + const localEndedOrStartedInside = endedOrStartedInside; + endedOrStartedInside = false; - if (this.#outsidePressEvent === "click" && localEndedOrStartedInside) { + if (outsidePressEvent === "click" && localEndedOrStartedInside) { return; } if (localInsideTree) return; - if ( - typeof this.#outsidePress === "function" && - !this.#outsidePress(event) - ) { + if (typeof outsidePress === "function" && !outsidePress(event)) { return; } const target = getTarget(event); const inertSelector = `[${createAttribute("inert")}]`; - const markers = getDocument(this.context.floating).querySelectorAll( + const markers = getDocument(context.floating).querySelectorAll( inertSelector, ); @@ -361,7 +233,7 @@ class DismissInteraction implements ElementProps { isElement(target) && !isRootElement(target) && // Clicked on a direct ancestor (e.g. FloatingOverlay). - !contains(target, this.context.floating) && + !contains(target, context.floating) && // If the target root element contains none of the markers, then the // element was injected after the floating element rendered. Array.from(markers).every( @@ -372,7 +244,7 @@ class DismissInteraction implements ElementProps { } // Check if the click occurred on the scrollbar - if (isHTMLElement(target) && this.context.floating) { + if (isHTMLElement(target) && context.floating) { const lastTraversableNode = isLastTraversableNode(target); const style = getComputedStyle(target); const scrollRe = /auto|scroll/; @@ -411,9 +283,9 @@ class DismissInteraction implements ElementProps { } } - const nodeId = this.context.data.floatingContext?.nodeId; + const nodeId = context.data.floatingContext?.nodeId; - const children = this.#tree ? getChildren(this.#tree.nodes, nodeId) : []; + const children = tree ? getChildren(tree.nodes, nodeId) : []; const targetIsInsideChildren = children.length && @@ -422,8 +294,8 @@ class DismissInteraction implements ElementProps { ); if ( - isEventTargetWithin(event, this.context.floating) || - isEventTargetWithin(event, this.context.domReference) || + isEventTargetWithin(event, context.floating) || + isEventTargetWithin(event, context.domReference) || targetIsInsideChildren ) { return; @@ -438,62 +310,164 @@ class DismissInteraction implements ElementProps { if (hasOpenChild) return; } - this.context.onOpenChange(false, event, "outside-press"); - }; + context.onOpenChange(false, event, "outside-press"); + } - #closeOnPressOutsideCapture = (event: MouseEvent) => { + function closeOnPressOutsideCapture(event: MouseEvent) { const callback = () => { - this.#closeOnPressOutside(event); - getTarget(event)?.removeEventListener(this.#outsidePressEvent, callback); + closeOnPressOutside(event); + getTarget(event)?.removeEventListener(outsidePressEvent, callback); + }; + getTarget(event)?.addEventListener(outsidePressEvent, callback); + } + + $effect(() => { + if (!context.open || !enabled) return; + untrack(() => { + context.data.__escapeKeyBubbles = bubbleOptions.escapeKey; + context.data.__outsidePressBubbles = bubbleOptions.outsidePress; + }); + + let compositionTimeout = -1; + + const onScroll = (event: Event) => { + context.onOpenChange(false, event, "ancestor-scroll"); + }; + + const handleCompositionStart = () => { + window.clearTimeout(compositionTimeout); + isComposing = true; }; - getTarget(event)?.addEventListener(this.#outsidePressEvent, callback); - }; - #reference = $derived.by(() => { - if (!this.#enabled) return {}; - return { - onkeydown: this.#closeOnEscapeKeyDown, - ...(this.#referencePress && { - [bubbleHandlerKeys[this.#referencePressEvent]]: (event: Event) => { - this.context.onOpenChange(false, event, "reference-press"); + const handleCompositionEnd = () => { + // Safari fires `compositionend` before `keydown`, so we need to wait + // until the next tick to set `isComposing` to `false`. + // https://bugs.webkit.org/show_bug.cgi?id=165004 + compositionTimeout = window.setTimeout( + () => { + isComposing = false; }, - ...(this.#referencePressEvent !== "click" && { - onclick: (event: MouseEvent) => { - this.context.onOpenChange(false, event, "reference-press"); - }, - }), - }), + // 0ms or 1ms don't work in Safari. 5ms appears to consistently work. + // Only apply to WebKit for the test to remain 0ms. + isWebKit() ? 5 : 0, + ); }; - }); - #floating = $derived.by(() => { - if (!this.#enabled) return {}; - return { - onkeydown: this.#closeOnEscapeKeyDown, - onmousedown: () => { - this.#endedOrStartedInside = true; - }, - onmouseup: () => { - this.#endedOrStartedInside = true; - }, - [captureHandlerKeys[this.#outsidePressEvent]]: () => { - this.#insideTree = true; - }, + const doc = getDocument(context.floating); + + const listenersToRemove: Array<() => void> = []; + + if (escapeKey) { + listenersToRemove.push( + on( + doc, + "keydown", + captureOptions.escapeKey + ? closeOnEscapeKeyDownCapture + : closeOnEscapeKeyDown, + { capture: captureOptions.escapeKey }, + ), + on(doc, "compositionstart", handleCompositionStart), + on(doc, "compositionend", handleCompositionEnd), + ); + } + + if (outsidePress) { + listenersToRemove.push( + on( + doc, + outsidePressEvent, + captureOptions.outsidePress + ? closeOnPressOutsideCapture + : closeOnPressOutside, + { capture: captureOptions.outsidePress }, + ), + ); + } + + let ancestors: (Element | Window | VisualViewport)[] = []; + + if (ancestorScroll) { + if (isElement(context.domReference)) { + ancestors = getOverflowAncestors(context.domReference); + } + + if (isElement(context.floating)) { + ancestors = ancestors.concat(getOverflowAncestors(context.floating)); + } + + if ( + !isElement(context.reference) && + context.reference && + context.reference.contextElement + ) { + ancestors = ancestors.concat( + getOverflowAncestors(context.reference.contextElement), + ); + } + } + + // Ignore the visual viewport for scrolling dismissal (allow pinch-zoom) + ancestors = ancestors.filter( + (ancestor) => ancestor !== doc.defaultView?.visualViewport, + ); + + for (const ancestor of ancestors) { + listenersToRemove.push( + on(ancestor, "scroll", onScroll, { passive: true }), + ); + } + + return () => { + for (const removeListener of listenersToRemove) { + removeListener(); + } + window.clearTimeout(compositionTimeout); }; }); - get reference() { - return this.#reference; - } + watch([() => outsidePress, () => outsidePressEvent], () => { + insideTree = false; + }); - get floating() { - return this.#floating; - } -} + const reference = $derived({ + onkeydown: closeOnEscapeKeyDown, + ...(referencePress && { + [bubbleHandlerKeys[referencePressEvent]]: (event: Event) => { + context.onOpenChange(false, event, "reference-press"); + }, + ...(referencePressEvent !== "click" && { + onclick: (event: MouseEvent) => { + context.onOpenChange(false, event, "reference-press"); + }, + }), + }), + }); -function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { - return new DismissInteraction(context, options); + const floating = $derived({ + onkeydown: closeOnEscapeKeyDown, + onmousedown: () => { + endedOrStartedInside = true; + }, + onmouseup: () => { + endedOrStartedInside = true; + }, + [captureHandlerKeys[outsidePressEvent]]: () => { + insideTree = true; + }, + }); + + return { + get reference() { + if (!enabled) return {}; + return reference; + }, + get floating() { + if (!enabled) return {}; + return floating; + }, + }; } export type { UseDismissOptions }; -export { useDismiss, DismissInteraction as DismissState }; +export { useDismiss }; diff --git a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts index 1f6a9f81..eca8dab9 100644 --- a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts @@ -14,7 +14,7 @@ import type { FloatingContext } from "./use-floating.svelte.js"; import { on } from "svelte/events"; import { executeCallbacks } from "../internal/execute-callbacks.js"; import { extract } from "../internal/extract.js"; -import { watch, watchOnce } from "../internal/watch.svelte.js"; +import { watch } from "../internal/watch.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; interface UseFocusOptions { @@ -32,90 +32,30 @@ interface UseFocusOptions { visibleOnly?: MaybeGetter; } -class FocusInteraction implements ElementProps { - #enabled = $derived.by(() => extract(this.options.enabled, true)); - #visibleOnly = $derived.by(() => extract(this.options.visibleOnly, true)); - #blockFocus = false; - #timeout = -1; - #keyboardModality = true; - - constructor( - private readonly context: FloatingContext, - private readonly options: UseFocusOptions = {}, - ) { - watch( - [() => this.#enabled, () => this.context.domReference], - ([enabled, domReference]) => { - if (!enabled) return; - - const win = getWindow(domReference); - - // If the domReference was focused and the user left the tab/window, and the - // floating element was not open, the focus should be blocked when they - // return to the tab/window. - const onBlur = () => { - if ( - !context.open && - isHTMLElement(domReference) && - domReference === activeElement(getDocument(domReference)) - ) { - this.#blockFocus = true; - } - }; - - const onKeyDown = () => { - this.#keyboardModality = true; - }; - - return executeCallbacks( - on(win, "blur", onBlur), - on(win, "keydown", onKeyDown, { capture: true }), - ); - }, - ); - - watch( - () => this.#enabled, - (enabled) => { - if (!enabled) return; - - const onOpenChange = ({ reason }: { reason: OpenChangeReason }) => { - if (reason === "reference-press" || reason === "escape-key") { - this.#blockFocus = true; - } - }; - - this.context.events.on("openchange", onOpenChange); - return () => { - this.context.events.off("openchange", onOpenChange); - }; - }, - ); - - $effect(() => { - return () => { - window.clearTimeout(this.#timeout); - }; - }); - } +function useFocus(context: FloatingContext, opts: UseFocusOptions = {}) { + const enabled = $derived(extract(opts.enabled, true)); + const visibleOnly = $derived(extract(opts.visibleOnly, true)); + let blockFocus = false; + let timeout = -1; + let keyboardModality = true; - #onpointerdown = (event: PointerEvent) => { + function onpointerdown(event: PointerEvent) { if (isVirtualPointerEvent(event)) return; - this.#keyboardModality = false; - }; + keyboardModality = false; + } - #onmouseleave = () => { - this.#blockFocus = false; - }; + function onmouseleave() { + blockFocus = false; + } - #onfocus = (event: FocusEvent) => { - if (this.#blockFocus) { + function onfocus(event: FocusEvent) { + if (blockFocus) { return; } const target = getTarget(event); - if (this.#visibleOnly && isElement(target)) { + if (visibleOnly && isElement(target)) { try { // Mac Safari unreliably matches `:focus-visible` on the reference // if focus was outside the page initially - use the fallback @@ -124,17 +64,17 @@ class FocusInteraction implements ElementProps { if (!target.matches(":focus-visible")) return; } catch { // Old browsers will throw an error when using `:focus-visible`. - if (!this.#keyboardModality && !isTypeableElement(target)) { + if (!keyboardModality && !isTypeableElement(target)) { return; } } } - this.context.onOpenChange(true, event, "focus"); - }; + context.onOpenChange(true, event, "focus"); + } - #onblur = (event: FocusEvent) => { - this.#blockFocus = false; + function onblur(event: FocusEvent) { + blockFocus = false; const relatedTarget = event.relatedTarget; // Hit the non-modal focus management portal guard. Focus will be @@ -145,15 +85,15 @@ class FocusInteraction implements ElementProps { relatedTarget.getAttribute("data-type") === "outside"; // Wait for the window blur listener to fire. - this.#timeout = window.setTimeout(() => { + timeout = window.setTimeout(() => { const activeEl = activeElement( - isElement(this.context.domReference) - ? this.context.domReference.ownerDocument + isElement(context.domReference) + ? context.domReference.ownerDocument : document, ); // Focus left the page, keep it open. - if (!relatedTarget && activeEl === this.context.domReference) return; + if (!relatedTarget && activeEl === context.domReference) return; // When focusing the reference element (e.g. regular click), then // clicking into the floating element, prevent it from hiding. @@ -163,35 +103,86 @@ class FocusInteraction implements ElementProps { // and not the element that actually has received focus if it is located // inside a shadow root. if ( - contains(this.context.floating, activeEl) || - contains(this.context.domReference, activeEl) || + contains(context.floating, activeEl) || + contains(context.domReference, activeEl) || movedToFocusGuard ) { return; } - this.context.onOpenChange(false, event, "focus"); + context.onOpenChange(false, event, "focus"); }); - }; + } + + watch( + [() => enabled, () => context.domReference], + ([enabled, domReference]) => { + if (!enabled) return; + + const win = getWindow(domReference); + + // If the domReference was focused and the user left the tab/window, and the + // floating element was not open, the focus should be blocked when they + // return to the tab/window. + const onBlur = () => { + if ( + !context.open && + isHTMLElement(domReference) && + domReference === activeElement(getDocument(domReference)) + ) { + blockFocus = true; + } + }; - #reference = $derived.by(() => { - if (!this.#enabled) return {}; - return { - onpointerdown: this.#onpointerdown, - onmouseleave: this.#onmouseleave, - onfocus: this.#onfocus, - onblur: this.#onblur, + const onKeyDown = () => { + keyboardModality = true; + }; + + return executeCallbacks( + on(win, "blur", onBlur), + on(win, "keydown", onKeyDown, { capture: true }), + ); + }, + ); + + watch( + () => enabled, + (enabled) => { + if (!enabled) return; + + const onOpenChange = ({ reason }: { reason: OpenChangeReason }) => { + if (reason === "reference-press" || reason === "escape-key") { + blockFocus = true; + } + }; + + context.events.on("openchange", onOpenChange); + return () => { + context.events.off("openchange", onOpenChange); + }; + }, + ); + + $effect(() => { + return () => { + window.clearTimeout(timeout); }; }); - get reference() { - return this.#reference; - } -} + const reference = $derived({ + onpointerdown: onpointerdown, + onmouseleave: onmouseleave, + onfocus: onfocus, + onblur: onblur, + }); -function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { - return new FocusInteraction(context, options); + return { + get reference() { + if (!enabled) return {}; + return reference; + }, + }; } export type { UseFocusOptions }; -export { useFocus, FocusInteraction as FocusState }; +export { useFocus }; diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index 55466c19..0461c11d 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -111,386 +111,356 @@ function getDelay( return value?.[prop]; } -class HoverInteraction implements ElementProps { - #enabled = $derived.by(() => extract(this.options.enabled, true)); - #mouseOnly = $derived.by(() => extract(this.options.mouseOnly, false)); - #delay = $derived.by(() => extract(this.options.delay, 0)); - #restMs = $derived.by(() => extract(this.options.restMs, 0)); - #move = $derived.by(() => extract(this.options.move, true)); - #handleClose = $state(null); - #tree: FloatingTreeType | null = null; - #parentId: string | null = null; - #pointerType: PointerType | undefined = undefined; - #timeout = -1; - #handler: ((event: MouseEvent) => void) | undefined = undefined; - #restTimeout = -1; - #blockMouseMove = true; - #performedPointerEventsMutation = false; - #unbindMouseMove = noop; - #restTimeoutPending = false; - #isHoverOpen = $derived.by(() => { - const type = this.context.data.openEvent?.type; +function useHover(context: FloatingContext, options: UseHoverOptions = {}) { + const enabled = $derived(extract(options.enabled, true)); + const mouseOnly = $derived(extract(options.mouseOnly, false)); + const delay = $derived(extract(options.delay, 0)); + const restMs = $derived(extract(options.restMs, 0)); + const move = $derived(extract(options.move, true)); + const handleClose = $state(null); + const tree = useFloatingTree(); + const parentId = useFloatingParentNodeId(); + let pointerType: PointerType | undefined = undefined; + let timeout = -1; + let handler: ((event: MouseEvent) => void) | undefined = undefined; + let restTimeout = -1; + let blockMouseMove = true; + let performedPointerEventsMutation = false; + let unbindMouseMove = noop; + let restTimeoutPending = false; + const isHoverOpen = $derived.by(() => { + const type = context.data.openEvent?.type; return type?.includes("mouse") && type !== "mousedown"; }); - #isClickLikeOpenEvent = $derived.by(() => { - return this.context.data.openEvent - ? ["click", "mousedown"].includes(this.context.data.openEvent.type) + const isClickLikeOpenEvent = $derived.by(() => { + return context.data.openEvent + ? ["click", "mousedown"].includes(context.data.openEvent.type) : false; }); - constructor( - private readonly context: FloatingContext, - private readonly options: UseHoverOptions = {}, + function closeWithDelay( + event: Event, + runElseBranch = true, + reason: OpenChangeReason = "hover", ) { - this.#handleClose = options.handleClose; - - $effect.pre(() => { - this.#handleClose = options.handleClose; - }); - - this.#tree = useFloatingTree(); - this.#parentId = useFloatingParentNodeId(); - - watch( - [() => this.#enabled, () => this.context.events], - ([enabled, events]) => { - if (!enabled) return; - - const onOpenChange = ({ open }: { open: boolean }) => { - if (!open) { - window.clearTimeout(this.#timeout); - window.clearTimeout(this.#restTimeout); - this.#blockMouseMove = true; - this.#restTimeoutPending = false; - } - }; + const closeDelay = getDelay(delay, "close", pointerType); + if (closeDelay && !handler) { + window.clearTimeout(timeout); + timeout = window.setTimeout( + () => context.onOpenChange(false, event, reason), + closeDelay, + ); + } else if (runElseBranch) { + window.clearTimeout(timeout); + context.onOpenChange(false, event, reason); + } + } - events.on("openchange", onOpenChange); + function cleanupMouseMoveHandler() { + unbindMouseMove(); + handler = undefined; + } - return () => { - events.off("openchange", onOpenChange); - }; - }, - ); - - watch( - [ - () => this.#enabled, - () => this.#handleClose, - () => this.context.open, - () => this.context.floating, - () => this.#isHoverOpen, - ], - ([enabled, handleClose, open, floating]) => { - if (!enabled || !handleClose || !open) return; - - const onLeave = (event: MouseEvent) => { - if (!this.#isHoverOpen) return; - this.context.onOpenChange(false, event, "hover"); - }; + function clearPointerEvents() { + if (!performedPointerEventsMutation) return; - const html = getDocument(floating).documentElement; - return on(html, "mouseleave", onLeave); - }, - ); - - // Registering the mouse events on the reference directly to bypass Svelte's - // delegation system. If the cursor was on a disabled element and then entered - // the reference (no gap), `mouseenter` doesn't fire in the delegation system. - $effect(() => { - if (!this.#enabled) return; - - const onMouseEnter = (event: MouseEvent) => { - window.clearTimeout(this.#timeout); - this.#blockMouseMove = false; - - if ( - (this.#mouseOnly && !isMouseLikePointerType(this.#pointerType)) || - (this.#restMs > 0 && !getDelay(this.#delay, "open")) - ) { - return; - } + const body = getDocument(context.floating).body; + body.style.pointerEvents = ""; + body.removeAttribute(safePolygonIdentifier); + performedPointerEventsMutation = false; + } - const openDelay = getDelay(this.#delay, "open", this.#pointerType); + function setPointerType(event: PointerEvent) { + if (!isPointerType(event.pointerType)) return; + pointerType = event.pointerType; + } - if (openDelay) { - this.#timeout = window.setTimeout(() => { - if (!context.open) { - context.onOpenChange(true, event, "hover"); - } - }, openDelay); - } else if (!context.open) { - context.onOpenChange(true, event, "hover"); - } - }; + function onReferenceMouseMove(event: MouseEvent) { + const handleMouseMove = () => { + if (!blockMouseMove && !context.open) { + context.onOpenChange(true, event, "hover"); + } + }; + + if (mouseOnly && !isMouseLikePointerType(pointerType)) return; + if (context.open || restMs === 0) return; + + // ignore insignificant movements to account for tremors + if (restTimeoutPending && event.movementX ** 2 + event.movementY ** 2 < 2) { + return; + } - const onMouseLeave = (event: MouseEvent) => { - if (this.#isClickLikeOpenEvent) return; + window.clearTimeout(restTimeout); - this.#unbindMouseMove(); + if (pointerType === "touch") { + handleMouseMove(); + } else { + restTimeoutPending = true; + restTimeout = window.setTimeout(handleMouseMove, restMs); + } + } - const doc = getDocument(context.floating); - window.clearTimeout(this.#restTimeout); - this.#restTimeoutPending = false; + function onFloatingMouseEnter() { + window.clearTimeout(timeout); + } - if (this.#handleClose && context.data.floatingContext) { - // Prevent clearing `onScrollMouseLeave` timeout. - if (!context.open) { - window.clearTimeout(this.#timeout); - } + function onFloatingMouseLeave(event: MouseEvent) { + if (isClickLikeOpenEvent) return; + closeWithDelay(event, false); + } - this.#handler = this.#handleClose({ - ...snapshotFloatingContext(context).current, - tree: this.#tree, - x: event.clientX, - y: event.clientY, - onClose: () => { - this.#clearPointerEvents(); - this.#cleanupMouseMoveHandler(); - if (!this.#isClickLikeOpenEvent) { - this.#closeWithDelay(event, true, "safe-polygon"); - } - }, - }); - - const handler = this.#handler; - - doc.addEventListener("mousemove", handler); - this.#unbindMouseMove = () => { - doc.removeEventListener("mousemove", handler); - }; - - return; - } + watch([() => enabled, () => context.events], ([enabled, events]) => { + if (!enabled) return; - // Allow interactivity without `safePolygon` on touch devices. With a - // pointer, a short close delay is an alternative, so it should work - // consistently. - const shouldClose = - this.#pointerType === "touch" - ? !contains(context.floating, event.relatedTarget as Element | null) - : true; - if (shouldClose) { - this.#closeWithDelay(event); - } - }; + const onOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + window.clearTimeout(timeout); + window.clearTimeout(restTimeout); + blockMouseMove = true; + restTimeoutPending = false; + } + }; - // Ensure the floating element closes after scrolling even if the pointer - // did not move. - // https://github.com/floating-ui/floating-ui/discussions/1692 - const onScrollMouseLeave = (event: MouseEvent) => { - if (this.#isClickLikeOpenEvent) return; - if (!context.data.floatingContext) return; + events.on("openchange", onOpenChange); - this.#handleClose?.({ - ...snapshotFloatingContext(context.data.floatingContext).current, - tree: this.#tree, - x: event.clientX, - y: event.clientY, - onClose: () => { - this.#clearPointerEvents(); - this.#cleanupMouseMoveHandler(); - if (!this.#isClickLikeOpenEvent) { - this.#closeWithDelay(event); - } - }, - })(event); + return () => { + events.off("openchange", onOpenChange); + }; + }); + + watch( + [ + () => enabled, + () => handleClose, + () => context.open, + () => context.floating, + () => isHoverOpen, + ], + ([enabled, handleClose, open, floating]) => { + if (!enabled || !handleClose || !open) return; + + const onLeave = (event: MouseEvent) => { + if (!isHoverOpen) return; + context.onOpenChange(false, event, "hover"); }; - if (isElement(context.domReference)) { - const ref = context.domReference as unknown as HTMLElement; - context.open && ref.addEventListener("mouseleave", onScrollMouseLeave); - context.floating?.addEventListener("mouseleave", onScrollMouseLeave); - this.#move && - ref.addEventListener("mousemove", onMouseEnter, { once: true }); - ref.addEventListener("mouseenter", onMouseEnter); - ref.addEventListener("mouseleave", onMouseLeave); - return () => { - context.open && - ref.removeEventListener("mouseleave", onScrollMouseLeave); - context.floating?.removeEventListener( - "mouseleave", - onScrollMouseLeave, - ); - this.#move && ref.removeEventListener("mousemove", onMouseEnter); - ref.removeEventListener("mouseenter", onMouseEnter); - ref.removeEventListener("mouseleave", onMouseLeave); - }; + const html = getDocument(floating).documentElement; + return on(html, "mouseleave", onLeave); + }, + ); + + // Registering the mouse events on the reference directly to bypass Svelte's + // delegation system. If the cursor was on a disabled element and then entered + // the reference (no gap), `mouseenter` doesn't fire in the delegation system. + $effect(() => { + if (!enabled) return; + + const onMouseEnter = (event: MouseEvent) => { + window.clearTimeout(timeout); + blockMouseMove = false; + + if ( + (mouseOnly && !isMouseLikePointerType(pointerType)) || + (restMs > 0 && !getDelay(delay, "open")) + ) { + return; } - }); - - // Block pointer-events of every element other than the reference and floating - // while the floating element is open and has a `handleClose` handler. Also - // handles nested floating elements. - // https://github.com/floating-ui/floating-ui/issues/1722 - watch( - [ - () => this.#enabled, - () => this.context.open, - () => this.context.floating, - () => this.context.domReference, - () => this.#handleClose, - () => this.#isHoverOpen, - ], - ([enabled, open, floating, domReference, handleClose, treeNodes]) => { - if (!enabled) return; - if ( - open && - handleClose?.__options.blockPointerEvents && - this.#isHoverOpen - ) { - this.#performedPointerEventsMutation = true; - const floatingEl = floating; - if (!isElement(domReference) || !floatingEl) return; - - const body = getDocument(floating).body; - body.setAttribute(safePolygonIdentifier, ""); - - const ref = domReference as unknown as HTMLElement | SVGSVGElement; - - const parentFloating = this.#tree?.nodes.find( - (node) => node.id === this.#parentId, - )?.context?.floating; - - if (parentFloating) { - parentFloating.style.pointerEvents = ""; - } - body.style.pointerEvents = "none"; - ref.style.pointerEvents = "auto"; - floatingEl.style.pointerEvents = "auto"; + const openDelay = getDelay(delay, "open", pointerType); - return () => { - body.style.pointerEvents = ""; - ref.style.pointerEvents = ""; - floatingEl.style.pointerEvents = ""; - }; - } - }, - ); - - $effect(() => { - if (!this.context.open) { - this.#pointerType = undefined; - this.#restTimeoutPending = false; - this.#cleanupMouseMoveHandler(); - this.#clearPointerEvents(); + if (openDelay) { + timeout = window.setTimeout(() => { + if (!context.open) { + context.onOpenChange(true, event, "hover"); + } + }, openDelay); + } else if (!context.open) { + context.onOpenChange(true, event, "hover"); } - }); + }; - watch([() => this.#enabled, () => this.context.domReference], () => { - return () => { - this.#cleanupMouseMoveHandler(); - window.clearTimeout(this.#timeout); - window.clearTimeout(this.#restTimeout); - this.#clearPointerEvents(); - }; - }); - } + const onMouseLeave = (event: MouseEvent) => { + if (isClickLikeOpenEvent) return; - #closeWithDelay = ( - event: Event, - runElseBranch = true, - reason: OpenChangeReason = "hover", - ) => { - const closeDelay = getDelay(this.#delay, "close", this.#pointerType); - if (closeDelay && !this.#handler) { - window.clearTimeout(this.#timeout); - this.#timeout = window.setTimeout( - () => this.context.onOpenChange(false, event, reason), - closeDelay, - ); - } else if (runElseBranch) { - window.clearTimeout(this.#timeout); - this.context.onOpenChange(false, event, reason); - } - }; + unbindMouseMove(); - #cleanupMouseMoveHandler = () => { - this.#unbindMouseMove(); - this.#handler = undefined; - }; + const doc = getDocument(context.floating); + window.clearTimeout(restTimeout); + restTimeoutPending = false; - #clearPointerEvents = () => { - if (!this.#performedPointerEventsMutation) return; + if (handleClose && context.data.floatingContext) { + // Prevent clearing `onScrollMouseLeave` timeout. + if (!context.open) { + window.clearTimeout(timeout); + } - const body = getDocument(this.context.floating).body; - body.style.pointerEvents = ""; - body.removeAttribute(safePolygonIdentifier); - this.#performedPointerEventsMutation = false; - }; + handler = handleClose({ + ...snapshotFloatingContext(context).current, + tree: tree, + x: event.clientX, + y: event.clientY, + onClose: () => { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent) { + closeWithDelay(event, true, "safe-polygon"); + } + }, + }); - #setPointerType = (event: PointerEvent) => { - if (!isPointerType(event.pointerType)) return; - this.#pointerType = event.pointerType; - }; + const localHandler = handler; - #onReferenceMouseMove = (event: MouseEvent) => { - const handleMouseMove = () => { - if (!this.#blockMouseMove && !this.context.open) { - this.context.onOpenChange(true, event, "hover"); + doc.addEventListener("mousemove", localHandler); + unbindMouseMove = () => { + doc.removeEventListener("mousemove", localHandler); + }; + + return; + } + + // Allow interactivity without `safePolygon` on touch devices. With a + // pointer, a short close delay is an alternative, so it should work + // consistently. + const shouldClose = + pointerType === "touch" + ? !contains(context.floating, event.relatedTarget as Element | null) + : true; + if (shouldClose) { + closeWithDelay(event); } }; - if (this.#mouseOnly && !isMouseLikePointerType(this.#pointerType)) return; - if (this.context.open || this.#restMs === 0) return; + // Ensure the floating element closes after scrolling even if the pointer + // did not move. + // https://github.com/floating-ui/floating-ui/discussions/1692 + const onScrollMouseLeave = (event: MouseEvent) => { + if (isClickLikeOpenEvent) return; + if (!context.data.floatingContext) return; + + handleClose?.({ + ...snapshotFloatingContext(context.data.floatingContext).current, + tree: tree, + x: event.clientX, + y: event.clientY, + onClose: () => { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent) { + closeWithDelay(event); + } + }, + })(event); + }; - // ignore insignificant movements to account for tremors - if ( - this.#restTimeoutPending && - event.movementX ** 2 + event.movementY ** 2 < 2 - ) { - return; + if (isElement(context.domReference)) { + const ref = context.domReference as unknown as HTMLElement; + context.open && ref.addEventListener("mouseleave", onScrollMouseLeave); + context.floating?.addEventListener("mouseleave", onScrollMouseLeave); + move && ref.addEventListener("mousemove", onMouseEnter, { once: true }); + ref.addEventListener("mouseenter", onMouseEnter); + ref.addEventListener("mouseleave", onMouseLeave); + return () => { + context.open && + ref.removeEventListener("mouseleave", onScrollMouseLeave); + context.floating?.removeEventListener("mouseleave", onScrollMouseLeave); + move && ref.removeEventListener("mousemove", onMouseEnter); + ref.removeEventListener("mouseenter", onMouseEnter); + ref.removeEventListener("mouseleave", onMouseLeave); + }; } + }); - window.clearTimeout(this.#restTimeout); + // Block pointer-events of every element other than the reference and floating + // while the floating element is open and has a `handleClose` handler. Also + // handles nested floating elements. + // https://github.com/floating-ui/floating-ui/issues/1722 + watch( + [ + () => enabled, + () => context.open, + () => context.floating, + () => context.domReference, + () => handleClose, + () => isHoverOpen, + ], + ([enabled, open, floating, domReference, handleClose, treeNodes]) => { + if (!enabled) return; + if (open && handleClose?.__options.blockPointerEvents && isHoverOpen) { + performedPointerEventsMutation = true; + const floatingEl = floating; + if (!isElement(domReference) || !floatingEl) return; + + const body = getDocument(floating).body; + body.setAttribute(safePolygonIdentifier, ""); + + const ref = domReference as unknown as HTMLElement | SVGSVGElement; + + const parentFloating = tree?.nodes.find((node) => node.id === parentId) + ?.context?.floating; + + if (parentFloating) { + parentFloating.style.pointerEvents = ""; + } - if (this.#pointerType === "touch") { - handleMouseMove(); - } else { - this.#restTimeoutPending = true; - this.#restTimeout = window.setTimeout(handleMouseMove, this.#restMs); - } - }; + body.style.pointerEvents = "none"; + ref.style.pointerEvents = "auto"; + floatingEl.style.pointerEvents = "auto"; - #onFloatingMouseEnter = () => { - window.clearTimeout(this.#timeout); - }; + return () => { + body.style.pointerEvents = ""; + ref.style.pointerEvents = ""; + floatingEl.style.pointerEvents = ""; + }; + } + }, + ); + + $effect(() => { + if (!context.open) { + pointerType = undefined; + restTimeoutPending = false; + cleanupMouseMoveHandler(); + clearPointerEvents(); + } + }); - #onFloatingMouseLeave = (event: MouseEvent) => { - if (this.#isClickLikeOpenEvent) return; - this.#closeWithDelay(event, false); - }; + watch([() => enabled, () => context.domReference], () => { + return () => { + cleanupMouseMoveHandler(); + window.clearTimeout(timeout); + window.clearTimeout(restTimeout); + clearPointerEvents(); + }; + }); - #reference = $derived.by(() => { - if (!this.#enabled) return {}; + const reference = $derived.by(() => { + if (!enabled) return {}; return { - onpointerdown: this.#setPointerType, - onpointerenter: this.#setPointerType, - onmousemove: this.#onReferenceMouseMove, + onpointerdown: setPointerType, + onpointerenter: setPointerType, + onmousemove: onReferenceMouseMove, }; }); - #floating = $derived.by(() => { - if (!this.#enabled) return {}; + const floating = $derived.by(() => { + if (!enabled) return {}; return { - onmouseenter: this.#onFloatingMouseEnter, - onmouseleave: this.#onFloatingMouseLeave, + onmouseenter: onFloatingMouseEnter, + onmouseleave: onFloatingMouseLeave, }; }); - get reference() { - return this.#reference; - } - - get floating() { - return this.#floating; - } -} - -function useHover(context: FloatingContext, options: UseHoverOptions = {}) { - return new HoverInteraction(context, options); + return { + get reference() { + if (!enabled) return {}; + return reference; + }, + get floating() { + if (!enabled) return {}; + return floating; + }, + }; } export type { UseHoverOptions, HandleCloseFn }; -export { useHover, HoverInteraction, getDelay }; +export { useHover, getDelay }; diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index 67b7847c..9f7b6d2c 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -1,11 +1,6 @@ import { DEV } from "esm-env"; import { extract } from "../internal/extract.js"; -import type { - Boxed, - Dimensions, - FloatingTreeType, - MaybeGetter, -} from "../types.js"; +import type { Boxed, Dimensions, MaybeGetter } from "../types.js"; import type { FloatingRootContext } from "./use-floating-root-context.svelte.js"; import type { FloatingContext } from "./use-floating.svelte.js"; import { warn } from "../internal/log.js"; @@ -216,10 +211,6 @@ function useListNavigation( isTypeableCombobox(context.domReference), ); - $effect(() => { - console.log("listref changed", $state.snapshot(listRef)); - }); - const hasActiveIndex = $derived(activeIndex != null); const ariaActiveDescendantProp = $derived.by(() => { diff --git a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts index 95ba7068..bb2b3971 100644 --- a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts @@ -42,79 +42,69 @@ const componentRoleToAriaRoleMap = new Map< ["label", false], ]); -class RoleInteraction implements ElementProps { - #enabled = $derived.by(() => extract(this.options.enabled, true)); - #role = $derived.by(() => extract(this.options.role, "dialog")); - #ariaRole = $derived( - (componentRoleToAriaRoleMap.get(this.#role) ?? this.#role) as +function useRole( + context: FloatingContext, + opts: UseRoleOptions = {}, +): ElementProps { + const enabled = $derived(extract(opts.enabled, true)); + const role = $derived(extract(opts.role, "dialog")); + const ariaRole = $derived( + (componentRoleToAriaRoleMap.get(role) ?? role) as | AriaRole | false | undefined, ); - #parentId: string | null = null; - #isNested: boolean; - #referenceId = useId(); + const parentId = useFloatingParentNodeId(); + const isNested = parentId != null; + const referenceId = useId(); - constructor( - private readonly context: FloatingContext, - private readonly options: UseRoleOptions = {}, - ) { - this.#parentId = useFloatingParentNodeId(); - this.#isNested = this.#parentId != null; - } - - #reference: ElementProps["reference"] = $derived.by(() => { - if (!this.#enabled) return {}; - if (this.#ariaRole === "tooltip" || this.#role === "label") { + const reference: ElementProps["reference"] = $derived.by(() => { + if (ariaRole === "tooltip" || role === "label") { return { - [`aria-${this.#role === "label" ? "labelledby" : "describedby"}`]: this - .context.open - ? this.context.floatingId - : undefined, + [`aria-${role === "label" ? "labelledby" : "describedby"}`]: + context.open ? context.floatingId : undefined, }; } return { - "aria-expanded": this.context.open ? "true" : "false", - "aria-haspopup": - this.#ariaRole === "alertdialog" ? "dialog" : this.#ariaRole, - "aria-controls": this.context.open ? this.context.floatingId : undefined, - ...(this.#ariaRole === "listbox" && { role: "combobox " }), - ...(this.#ariaRole === "menu" && { id: this.#referenceId }), - ...(this.#ariaRole === "menu" && this.#isNested && { role: "menuitem" }), - ...(this.#role === "select" && { "aria-autocomplete": "none" }), - ...(this.#role === "combobox" && { "aria-autocomplete": "list" }), + "aria-expanded": context.open ? "true" : "false", + "aria-haspopup": ariaRole === "alertdialog" ? "dialog" : ariaRole, + "aria-controls": context.open ? context.floatingId : undefined, + ...(ariaRole === "listbox" && { role: "combobox " }), + ...(ariaRole === "menu" && { id: referenceId }), + ...(ariaRole === "menu" && isNested && { role: "menuitem" }), + ...(role === "select" && { "aria-autocomplete": "none" }), + ...(role === "combobox" && { "aria-autocomplete": "list" }), }; }); - #floating: ElementProps["floating"] = $derived.by(() => { - if (!this.#enabled) return {}; + const floating: ElementProps["floating"] = $derived.by(() => { const floatingProps = { - id: this.context.floatingId, - ...(this.#ariaRole && { role: this.#ariaRole }), + id: context.floatingId, + ...(ariaRole && { role: ariaRole }), }; - if (this.#ariaRole === "tooltip" || this.#role === "label") { + if (ariaRole === "tooltip" || role === "label") { return floatingProps; } return { ...floatingProps, - ...(this.#ariaRole === "menu" && { - "aria-labelledby": this.#referenceId, + ...(ariaRole === "menu" && { + "aria-labelledby": referenceId, }), }; }); - #item: ElementProps["item"] = $derived.by(() => { + const item: ElementProps["item"] = $derived.by(() => { return ({ active, selected }: ExtendedUserProps) => { - if (!this.#enabled) return {}; + if (!enabled) return {}; const commonProps = { role: "option", - ...(active && { id: `${this.context.floatingId}-option` }), + ...(active && { id: `${context.floatingId}-option` }), }; - switch (this.#role) { + switch (role) { case "select": return { ...commonProps, @@ -132,25 +122,21 @@ class RoleInteraction implements ElementProps { }; }); - get reference() { - return this.#reference; - } - - get floating() { - return this.#floating; - } - - get item() { - return this.#item; - } -} - -function useRole( - context: FloatingContext, - options: UseRoleOptions = {}, -): ElementProps { - return new RoleInteraction(context, options); + return { + get reference() { + if (!enabled) return {}; + return reference; + }, + get item() { + if (!enabled) return {}; + return item; + }, + get floating() { + if (!enabled) return {}; + return floating; + }, + }; } export type { UseRoleOptions }; -export { useRole, RoleInteraction }; +export { useRole }; diff --git a/packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts index 4f508fa0..4a95a67e 100644 --- a/packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts @@ -55,69 +55,59 @@ interface UseTransitionStatusOptions { } type TransitionStatus = "unmounted" | "initial" | "open" | "close"; -class TransitionStatusState { - #duration = $derived.by(() => extract(this.options.duration, 250)); - #closeDuration = $derived.by(() => { - if (typeof this.#duration === "number") { - return this.#duration; +/** + * Provides a status string to apply CSS transitions to a floating element, + * correctly handling placement-aware transitions. + */ +function useTransitionStatus( + context: FloatingContext, + opts: UseTransitionStatusOptions = {}, +): { isMounted: boolean; status: TransitionStatus } { + const duration = $derived(extract(opts.duration, 250)); + const closeDuration = $derived.by(() => { + if (typeof duration === "number") { + return duration; } - return this.#duration.close || 0; + return duration.close || 0; + }); + let status: TransitionStatus = $state("unmounted"); + const isMounted = useDelayUnmount({ + open: () => context.open, + durationMs: () => closeDuration, }); - #status: TransitionStatus = $state("unmounted"); - #isMounted: ReturnType; - - constructor( - private readonly context: FloatingContext, - private readonly options: UseTransitionStatusOptions, - ) { - this.#isMounted = useDelayUnmount({ - open: () => this.context.open, - durationMs: () => this.#closeDuration, - }); - $effect.pre(() => { - if (!this.#isMounted.current && this.#status === "close") { - this.#status = "unmounted"; - } - }); + $effect.pre(() => { + if (!isMounted.current && status === "close") { + status = "unmounted"; + } + }); - watch.pre([() => this.context.open, () => this.context.floating], () => { - if (!this.context.floating) return; + watch.pre([() => context.open, () => context.floating], () => { + if (!context.floating) return; - if (this.context.open) { - this.#status = "initial"; + if (context.open) { + status = "initial"; - const frame = requestAnimationFrame(() => { - this.#status = "open"; - }); + const frame = requestAnimationFrame(() => { + status = "open"; + }); - return () => { - cancelAnimationFrame(frame); - }; - } - - this.#status = "close"; - }); - } - - get isMounted() { - return this.#isMounted.current; - } + return () => { + cancelAnimationFrame(frame); + }; + } - get status() { - return this.#status; - } -} + status = "close"; + }); -/** - * Provides a status string to apply CSS transitions to a floating element, - * correctly handling placement-aware transitions. - */ -function useTransitionStatus( - context: FloatingContext, - options: UseTransitionStatusOptions = {}, -): TransitionStatusState { - return new TransitionStatusState(context, options); + return { + get isMounted() { + return isMounted.current; + }, + get status() { + return status; + }, + }; } type CSSStylesProperty = @@ -145,112 +135,102 @@ interface UseTransitionStylesOptions extends UseTransitionStatusOptions { common?: CSSStylesProperty; } -class TransitionStylesState { - #initial = $derived.by(() => this.options.initial ?? { opacity: 0 }); - #open = $derived.by(() => this.options.open); - #close = $derived.by(() => this.options.close); - #common = $derived.by(() => this.options.common); - #duration = $derived.by(() => extract(this.options.duration, 250)); - #placement = $derived.by(() => this.context.placement); - #side = $derived.by(() => this.#placement.split("-")[0] as Side); - #fnArgs = $derived.by(() => ({ - side: this.#side, - placement: this.#placement, - })); - #openDuration = $derived.by(() => { - if (typeof this.#duration === "number") { - return this.#duration; +function useTransitionStyles( + context: FloatingContext, + opts: UseTransitionStylesOptions = {}, +): { + styles: string; + isMounted: boolean; +} { + const initial = $derived(opts.initial ?? { opacity: 0 }); + const open = $derived(opts.open); + const close = $derived(opts.close); + const common = $derived(opts.common); + const duration = $derived(extract(opts.duration, 250)); + const placement = $derived(context.placement); + const side = $derived(placement.split("-")[0] as Side); + const fnArgs = $derived({ side, placement }); + const openDuration = $derived.by(() => { + if (typeof duration === "number") { + return duration; } - return this.#duration.open || 0; + return duration.open || 0; }); - #closeDuration = $derived.by(() => { - if (typeof this.#duration === "number") { - return this.#duration; + const closeDuration = $derived.by(() => { + if (typeof duration === "number") { + return duration; } - return this.#duration.close || 0; + return duration.close || 0; }); - #styles = $state.raw({}); - #transitionStatus: TransitionStatusState; - #status = $derived.by(() => this.#transitionStatus.status); - - constructor( - private readonly context: FloatingContext, - private readonly options: UseTransitionStylesOptions = {}, - ) { - this.#styles = { - ...execWithArgsOrReturn(this.#common, this.#fnArgs), - ...execWithArgsOrReturn(this.#initial, this.#fnArgs), - }; - this.#transitionStatus = useTransitionStatus(context, { - duration: this.options.duration, - }); - - watch.pre( - [ - () => this.#closeDuration, - () => this.#close, - () => this.#initial, - () => this.#open, - () => this.#common, - () => this.#openDuration, - () => this.#status, - () => this.#fnArgs, - ], - () => { - const initialStyles = execWithArgsOrReturn(this.#initial, this.#fnArgs); - const closeStyles = execWithArgsOrReturn(this.#close, this.#fnArgs); - const commonStyles = execWithArgsOrReturn(this.#common, this.#fnArgs); - const openStyles = - execWithArgsOrReturn(this.#open, this.#fnArgs) || - Object.keys(initialStyles).reduce((acc: Record, key) => { - acc[key] = ""; - return acc; - }, {}); - - if (this.#status === "initial") { - this.#styles = { - "transition-property": this.#styles["transition-property"], - ...commonStyles, - ...initialStyles, - }; - } - - if (this.#status === "open") { - this.#styles = { - "transition-property": Object.keys(openStyles).join(", "), - "transition-duration": `${this.#openDuration}ms`, - ...commonStyles, - ...openStyles, - }; - } + let styles = $state.raw({ + ...execWithArgsOrReturn(common, fnArgs), + ...execWithArgsOrReturn(initial, fnArgs), + }); + const transitionStatus = useTransitionStatus(context, { + duration: opts.duration, + }); + const status = $derived(transitionStatus.status); + + watch.pre( + [ + () => closeDuration, + () => close, + () => initial, + () => open, + () => common, + () => openDuration, + () => status, + () => fnArgs, + ], + () => { + const initialStyles = execWithArgsOrReturn(initial, fnArgs); + const closeStyles = execWithArgsOrReturn(close, fnArgs); + const commonStyles = execWithArgsOrReturn(common, fnArgs); + const openStyles = + execWithArgsOrReturn(open, fnArgs) || + Object.keys(initialStyles).reduce((acc: Record, key) => { + acc[key] = ""; + return acc; + }, {}); + + if (status === "initial") { + styles = { + "transition-property": styles["transition-property"], + ...commonStyles, + ...initialStyles, + }; + } - if (this.#status === "close") { - const localStyles = closeStyles || initialStyles; - this.#styles = { - "transition-property": Object.keys(localStyles).join(", "), - "transition-duration": `${this.#closeDuration}ms`, - ...commonStyles, - ...localStyles, - }; - } - }, - ); - } + if (status === "open") { + styles = { + "transition-property": Object.keys(openStyles).join(", "), + "transition-duration": `${openDuration}ms`, + ...commonStyles, + ...openStyles, + }; + } - get styles() { - return styleObjectToString(this.#styles); - } + if (status === "close") { + const localStyles = closeStyles || initialStyles; + styles = { + "transition-property": Object.keys(localStyles).join(", "), + "transition-duration": `${closeDuration}ms`, + ...commonStyles, + ...localStyles, + }; + } + }, + ); - get isMounted() { - return this.#transitionStatus.isMounted; - } -} + return { + get styles() { + return styleObjectToString(styles); + }, -function useTransitionStyles( - context: FloatingContext, - options: UseTransitionStylesOptions = {}, -): TransitionStylesState { - return new TransitionStylesState(context, options); + get isMounted() { + return transitionStatus.isMounted; + }, + }; } export { useTransitionStyles, useTransitionStatus }; From 62f9a0741345e4ceb4aa43b055c8de6543ac174a Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 21 Jan 2025 22:23:17 -0500 Subject: [PATCH 56/87] need to debug why hover over nested breaks and closes --- biome.json | 10 + .../components/floating-list/hooks.svelte.ts | 9 +- .../floating-tree/floating-tree.svelte | 4 + .../hooks/use-floating-root-context.svelte.ts | 3 +- .../src/hooks/use-hover.svelte.ts | 263 ++++++++-------- .../src/hooks/use-list-navigation.svelte.ts | 1 - .../src/hooks/use-typeahead.svelte.ts | 239 +++++++++++++++ packages/floating-ui-svelte/src/index.ts | 1 + .../src/internal/create-pub-sub.ts | 6 +- .../floating-ui-svelte/src/safe-polygon.ts | 17 +- packages/floating-ui-svelte/src/types.ts | 9 +- .../unit/hooks/use-list-navigation.test.ts | 43 ++- .../test/unit/hooks/use-typeahead.test.ts | 135 +++++++++ .../use-typeahead/impl.svelte.ts | 64 ++++ .../use-typeahead/typeahead-combobox.svelte | 13 + .../use-typeahead/typeahead-full.svelte | 29 ++ .../use-typeahead/typeahead-select.svelte | 26 ++ .../components/emoji-picker/main.svelte | 5 +- .../components/listbox-focus/context.ts | 11 + .../components/listbox-focus/listbox.svelte | 32 ++ .../test/visual/components/menu/context.ts | 14 + .../test/visual/components/menu/main.svelte | 29 ++ .../menu/menu-context-provider.svelte | 24 ++ .../visual/components/menu/menu-impl.svelte | 283 ++++++++++++++++++ .../visual/components/menu/menu-item.svelte | 81 +++++ .../test/visual/components/menu/menu.svelte | 19 ++ .../test/visual/components/menu/types.ts | 8 + .../test/visual/routes/menu/+page.svelte | 5 + 28 files changed, 1246 insertions(+), 137 deletions(-) create mode 100644 packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts create mode 100644 packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/impl.svelte.ts create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-combobox.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-full.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-select.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/listbox-focus/context.ts create mode 100644 packages/floating-ui-svelte/test/visual/components/listbox-focus/listbox.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/menu/context.ts create mode 100644 packages/floating-ui-svelte/test/visual/components/menu/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/menu/menu-context-provider.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/menu/menu-item.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/menu/menu.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/menu/types.ts create mode 100644 packages/floating-ui-svelte/test/visual/routes/menu/+page.svelte diff --git a/biome.json b/biome.json index 5f32d260..09ca098d 100644 --- a/biome.json +++ b/biome.json @@ -17,6 +17,16 @@ } } } + }, + { + "include": ["*.ts"], + "linter": { + "rules": { + "suspicious": { + "noAssignInExpressions": "off" + } + } + } } ] } diff --git a/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts b/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts index 1156a4ec..794144e0 100644 --- a/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts +++ b/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts @@ -2,7 +2,7 @@ import { extract } from "../../internal/extract.js"; import type { MaybeGetter } from "../../types.js"; import { Context } from "../../internal/context.js"; import { watch } from "../../internal/watch.svelte.js"; -import type { SvelteMap } from "svelte/reactivity"; +import { SvelteMap } from "svelte/reactivity"; type FloatingListContextType = { register: (node: Node) => void; @@ -26,7 +26,12 @@ interface UseListItemOptions { */ function useListItem(opts: UseListItemOptions = {}) { const label = $derived(extract(opts.label)); - const listContext = FloatingListContext.get(); + const listContext = FloatingListContext.getOr({ + elements: [], + map: new SvelteMap(), + register: () => {}, + unregister: () => {}, + } as FloatingListContextType); let index = $state(null); let ref = $state(null); diff --git a/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte index db4a4ca9..940a8344 100644 --- a/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte +++ b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte @@ -18,6 +18,10 @@ FloatingTreeContext.set({ addNode: (node: FloatingNodeType) => { nodes = [...nodes, node]; + + return () => { + nodes = nodes.filter((n) => n !== node); + }; }, removeNode: (node: FloatingNodeType) => { nodes = nodes.filter((n) => n !== node); diff --git a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts index c283298c..8a1ff74b 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts @@ -126,8 +126,7 @@ class FloatingRootContext { } export function useFloatingRootContext(options: UseFloatingRootContextOptions) { - const optionsState = new FloatingRootContextOptions(options); - return new FloatingRootContext(optionsState); + return new FloatingRootContext(new FloatingRootContextOptions(options)); } export type { UseFloatingRootContextOptions }; diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index 0461c11d..fbd35cf0 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -117,7 +117,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { const delay = $derived(extract(options.delay, 0)); const restMs = $derived(extract(options.restMs, 0)); const move = $derived(extract(options.move, true)); - const handleClose = $state(null); + const handleClose: HandleCloseFn | null = options.handleClose ?? null; const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); let pointerType: PointerType | undefined = undefined; @@ -231,12 +231,11 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { watch( [ () => enabled, - () => handleClose, () => context.open, () => context.floating, () => isHoverOpen, ], - ([enabled, handleClose, open, floating]) => { + () => { if (!enabled || !handleClose || !open) return; const onLeave = (event: MouseEvent) => { @@ -244,7 +243,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { context.onOpenChange(false, event, "hover"); }; - const html = getDocument(floating).documentElement; + const html = getDocument(context.floating).documentElement; return on(html, "mouseleave", onLeave); }, ); @@ -252,50 +251,112 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { // Registering the mouse events on the reference directly to bypass Svelte's // delegation system. If the cursor was on a disabled element and then entered // the reference (no gap), `mouseenter` doesn't fire in the delegation system. - $effect(() => { - if (!enabled) return; + watch( + [ + () => context.domReference, + () => context.floating, + () => context.reference, + () => enabled, + () => mouseOnly, + () => restMs, + () => move, + () => open, + () => tree?.nodes, + () => delay, + () => context.data.floatingContext, + () => isClickLikeOpenEvent, + ], + () => { + if (!enabled) return; - const onMouseEnter = (event: MouseEvent) => { - window.clearTimeout(timeout); - blockMouseMove = false; + const onMouseEnter = (event: MouseEvent) => { + window.clearTimeout(timeout); + blockMouseMove = false; + const isOpen = context.open; + + if ( + (mouseOnly && !isMouseLikePointerType(pointerType)) || + (restMs > 0 && !getDelay(delay, "open")) + ) { + return; + } - if ( - (mouseOnly && !isMouseLikePointerType(pointerType)) || - (restMs > 0 && !getDelay(delay, "open")) - ) { - return; - } + const openDelay = getDelay(delay, "open", pointerType); - const openDelay = getDelay(delay, "open", pointerType); + if (openDelay) { + timeout = window.setTimeout(() => { + if (!isOpen) { + context.onOpenChange(true, event, "hover"); + } + }, openDelay); + } else if (!isOpen) { + context.onOpenChange(true, event, "hover"); + } + }; - if (openDelay) { - timeout = window.setTimeout(() => { - if (!context.open) { - context.onOpenChange(true, event, "hover"); - } - }, openDelay); - } else if (!context.open) { - context.onOpenChange(true, event, "hover"); - } - }; + const onMouseLeave = (event: MouseEvent) => { + console.log("mouse leave"); + if (isClickLikeOpenEvent) return; + const isOpen = context.open; - const onMouseLeave = (event: MouseEvent) => { - if (isClickLikeOpenEvent) return; + unbindMouseMove(); - unbindMouseMove(); + const doc = getDocument(context.floating); + window.clearTimeout(restTimeout); + restTimeoutPending = false; - const doc = getDocument(context.floating); - window.clearTimeout(restTimeout); - restTimeoutPending = false; + if (handleClose && context.data.floatingContext) { + // Prevent clearing `onScrollMouseLeave` timeout. + if (!isOpen) { + window.clearTimeout(timeout); + } - if (handleClose && context.data.floatingContext) { - // Prevent clearing `onScrollMouseLeave` timeout. - if (!context.open) { - window.clearTimeout(timeout); + handler = handleClose({ + ...snapshotFloatingContext(context).current, + tree: tree, + x: event.clientX, + y: event.clientY, + onClose: () => { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent) { + closeWithDelay(event, true, "safe-polygon"); + } + }, + }); + + const localHandler = handler; + + doc.addEventListener("mousemove", localHandler); + + unbindMouseMove = () => { + doc.removeEventListener("mousemove", localHandler); + }; + + return; } - handler = handleClose({ - ...snapshotFloatingContext(context).current, + // Allow interactivity without `safePolygon` on touch devices. With a + // pointer, a short close delay is an alternative, so it should work + // consistently. + const shouldClose = + pointerType === "touch" + ? !contains(context.floating, event.relatedTarget as Element | null) + : true; + if (shouldClose) { + closeWithDelay(event); + } + }; + + // Ensure the floating element closes after scrolling even if the pointer + // did not move. + // https://github.com/floating-ui/floating-ui/discussions/1692 + const onScrollMouseLeave = (event: MouseEvent) => { + if (isClickLikeOpenEvent) return; + if (!context.data.floatingContext) return; + + handleClose?.({ + ...snapshotFloatingContext(context.data.floatingContext).current, tree: tree, x: event.clientX, y: event.clientY, @@ -303,78 +364,38 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { clearPointerEvents(); cleanupMouseMoveHandler(); if (!isClickLikeOpenEvent) { - closeWithDelay(event, true, "safe-polygon"); + closeWithDelay(event); } }, - }); - - const localHandler = handler; - - doc.addEventListener("mousemove", localHandler); - unbindMouseMove = () => { - doc.removeEventListener("mousemove", localHandler); + })(event); + }; + const isOpen = context.open; + if (isElement(context.domReference)) { + const ref = context.domReference as unknown as HTMLElement; + isOpen && ref.addEventListener("mouseleave", onScrollMouseLeave); + context.floating?.addEventListener("mouseleave", onScrollMouseLeave); + move && ref.addEventListener("mousemove", onMouseEnter, { once: true }); + ref.addEventListener("mouseenter", onMouseEnter); + ref.addEventListener("mouseleave", onMouseLeave); + return () => { + isOpen && ref.removeEventListener("mouseleave", onScrollMouseLeave); + context.floating?.removeEventListener( + "mouseleave", + onScrollMouseLeave, + ); + move && ref.removeEventListener("mousemove", onMouseEnter); + ref.removeEventListener("mouseenter", onMouseEnter); + ref.removeEventListener("mouseleave", onMouseLeave); }; - - return; - } - - // Allow interactivity without `safePolygon` on touch devices. With a - // pointer, a short close delay is an alternative, so it should work - // consistently. - const shouldClose = - pointerType === "touch" - ? !contains(context.floating, event.relatedTarget as Element | null) - : true; - if (shouldClose) { - closeWithDelay(event); } - }; - - // Ensure the floating element closes after scrolling even if the pointer - // did not move. - // https://github.com/floating-ui/floating-ui/discussions/1692 - const onScrollMouseLeave = (event: MouseEvent) => { - if (isClickLikeOpenEvent) return; - if (!context.data.floatingContext) return; - - handleClose?.({ - ...snapshotFloatingContext(context.data.floatingContext).current, - tree: tree, - x: event.clientX, - y: event.clientY, - onClose: () => { - clearPointerEvents(); - cleanupMouseMoveHandler(); - if (!isClickLikeOpenEvent) { - closeWithDelay(event); - } - }, - })(event); - }; - - if (isElement(context.domReference)) { - const ref = context.domReference as unknown as HTMLElement; - context.open && ref.addEventListener("mouseleave", onScrollMouseLeave); - context.floating?.addEventListener("mouseleave", onScrollMouseLeave); - move && ref.addEventListener("mousemove", onMouseEnter, { once: true }); - ref.addEventListener("mouseenter", onMouseEnter); - ref.addEventListener("mouseleave", onMouseLeave); - return () => { - context.open && - ref.removeEventListener("mouseleave", onScrollMouseLeave); - context.floating?.removeEventListener("mouseleave", onScrollMouseLeave); - move && ref.removeEventListener("mousemove", onMouseEnter); - ref.removeEventListener("mouseenter", onMouseEnter); - ref.removeEventListener("mouseleave", onMouseLeave); - }; - } - }); + }, + ); // Block pointer-events of every element other than the reference and floating // while the floating element is open and has a `handleClose` handler. Also // handles nested floating elements. // https://github.com/floating-ui/floating-ui/issues/1722 - watch( + watch.pre( [ () => enabled, () => context.open, @@ -382,18 +403,25 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { () => context.domReference, () => handleClose, () => isHoverOpen, + () => tree?.nodes, ], - ([enabled, open, floating, domReference, handleClose, treeNodes]) => { + () => { if (!enabled) return; - if (open && handleClose?.__options.blockPointerEvents && isHoverOpen) { + if ( + context.open && + handleClose?.__options.blockPointerEvents && + isHoverOpen + ) { performedPointerEventsMutation = true; - const floatingEl = floating; - if (!isElement(domReference) || !floatingEl) return; + const floatingEl = context.floating; + if (!isElement(context.domReference) || !floatingEl) return; - const body = getDocument(floating).body; + const body = getDocument(context.floating).body; body.setAttribute(safePolygonIdentifier, ""); - const ref = domReference as unknown as HTMLElement | SVGSVGElement; + const ref = context.domReference as unknown as + | HTMLElement + | SVGSVGElement; const parentFloating = tree?.nodes.find((node) => node.id === parentId) ?.context?.floating; @@ -404,6 +432,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { body.style.pointerEvents = "none"; ref.style.pointerEvents = "auto"; + floatingEl.style.pointerEvents = "auto"; return () => { @@ -433,21 +462,15 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { }; }); - const reference = $derived.by(() => { - if (!enabled) return {}; - return { - onpointerdown: setPointerType, - onpointerenter: setPointerType, - onmousemove: onReferenceMouseMove, - }; + const reference = $derived({ + onpointerdown: setPointerType, + onpointerenter: setPointerType, + onmousemove: onReferenceMouseMove, }); - const floating = $derived.by(() => { - if (!enabled) return {}; - return { - onmouseenter: onFloatingMouseEnter, - onmouseleave: onFloatingMouseLeave, - }; + const floating = $derived({ + onmouseenter: onFloatingMouseEnter, + onmouseleave: onFloatingMouseLeave, }); return { diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index 9f7b6d2c..1eb12f36 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -633,7 +633,6 @@ function useListNavigation( ]; if (localIndex != null) { - console.log("localIndex", localIndex); index = localIndex; onNavigate(); } diff --git a/packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts new file mode 100644 index 00000000..524588e5 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts @@ -0,0 +1,239 @@ +import { stopEvent } from "../internal/dom.js"; +import { extract } from "../internal/extract.js"; +import { watch } from "../internal/watch.svelte.js"; +import type { MaybeGetter } from "../types.js"; +import type { FloatingRootContext } from "./use-floating-root-context.svelte.js"; +import type { FloatingContext } from "./use-floating.svelte.js"; +import type { ElementProps } from "./use-interactions.svelte.js"; + +interface UseTypeaheadOptions { + /** + * A ref which contains an array of strings whose indices match the HTML + * elements of the list. + * @default empty list + */ + listRef: MaybeGetter>; + /** + * The index of the active (focused or highlighted) item in the list. + * @default null + */ + activeIndex: MaybeGetter; + /** + * Callback invoked with the matching index if found as the user types. + */ + onMatch?: (index: number) => void; + /** + * Callback invoked with the typing state as the user types. + */ + onTypingChange?: (isTyping: boolean) => void; + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: MaybeGetter; + /** + * A function that returns the matching string from the list. + * @default lowercase-finder + */ + findMatch?: + | null + | (( + list: Array, + typedString: string, + ) => string | null | undefined); + /** + * The number of milliseconds to wait before resetting the typed string. + * @default 750 + */ + resetMs?: MaybeGetter; + /** + * An array of keys to ignore when typing. + * @default [] + */ + ignoreKeys?: MaybeGetter>; + /** + * The index of the selected item in the list, if available. + * @default null + */ + selectedIndex?: MaybeGetter; +} + +/** + * Provides a matching callback that can be used to focus an item as the user + * types, often used in tandem with `useListNavigation()`. + */ +function useTypeahead( + context: FloatingContext | FloatingRootContext, + opts: UseTypeaheadOptions, +): ElementProps { + const listRef = $derived(extract(opts.listRef, [])); + const activeIndex = $derived(extract(opts.activeIndex, null)); + const enabled = $derived(extract(opts.enabled, true)); + const findMatch = opts.findMatch ?? null; + const resetMs = $derived(extract(opts.resetMs, 750)); + const ignoreKeys = $derived(extract(opts.ignoreKeys, [])); + const selectedIndex = $derived(extract(opts.selectedIndex, null)); + + let prevIndex: number | null = selectedIndex ?? activeIndex ?? -1; + + let timeoutId = -1; + let str = ""; + let matchIndex: number | null = null; + + watch.pre( + () => open, + () => { + if (!open) return; + clearTimeout(timeoutId); + matchIndex = null; + str = ""; + }, + ); + + watch.pre([() => open, () => selectedIndex, () => activeIndex], () => { + // sync arrow key nav but not typeahead nav + if (context.open && str === "") { + prevIndex = selectedIndex ?? activeIndex ?? -1; + } + }); + + function setTypingChange(value: boolean) { + if (value) { + if (!context.data.typing) { + context.data.typing = value; + opts.onTypingChange?.(value); + } + } else { + if (context.data.typing) { + context.data.typing = value; + opts.onTypingChange?.(value); + } + } + } + + function getMatchingIndex( + list: Array, + orderedList: Array, + string: string, + ) { + const str = findMatch + ? findMatch(orderedList, string) + : orderedList.find( + (text) => + text?.toLocaleLowerCase().indexOf(string.toLocaleLowerCase()) === 0, + ); + + return str ? list.indexOf(str) : -1; + } + + function onkeydown(event: KeyboardEvent) { + const listContent = listRef; + const open = context.open; + + if (str.length > 0 && str[0] !== " ") { + if (getMatchingIndex(listContent, listContent, str) === -1) { + setTypingChange(false); + } else if (event.key === " ") { + stopEvent(event); + } + } + + if ( + listContent == null || + ignoreKeys.includes(event.key) || + // Character key. + event.key.length !== 1 || + // Modifier key. + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + + if (open && event.key !== " ") { + stopEvent(event); + setTypingChange(true); + } + + // Bail out if the list contains a word like "llama" or "aaron". TODO: + // allow it in this case, too. + const allowRapidSuccessionOfFirstLetter = listContent.every((text) => + text + ? text[0]?.toLocaleLowerCase() !== text[1]?.toLocaleLowerCase() + : true, + ); + + // Allows the user to cycle through items that start with the same letter + // in rapid succession. + if (allowRapidSuccessionOfFirstLetter && str === event.key) { + str = ""; + prevIndex = matchIndex; + } + + str += event.key; + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + str = ""; + prevIndex = matchIndex; + setTypingChange(false); + }, resetMs); + + const index = getMatchingIndex( + listContent, + [ + ...listContent.slice((prevIndex || 0) + 1), + ...listContent.slice(0, (prevIndex || 0) + 1), + ], + str, + ); + + if (index !== -1) { + opts.onMatch?.(index); + matchIndex = index; + } else if (event.key !== " ") { + str = ""; + setTypingChange(false); + } + } + + function floatingOnKeyUp(event: KeyboardEvent) { + if (event.key === " ") { + setTypingChange(false); + } + } + + $effect(() => { + return () => { + window.clearTimeout(timeoutId); + }; + }); + + const reference: ElementProps["reference"] = $derived( + enabled + ? { + onkeydown, + } + : {}, + ); + + const floating: ElementProps["floating"] = $derived({ + onkeydown, + onkeyup: floatingOnKeyUp, + }); + + return { + get reference() { + if (!enabled) return {}; + return reference; + }, + get floating() { + if (!enabled) return {}; + return floating; + }, + }; +} + +export { useTypeahead }; +export type { UseTypeaheadOptions }; diff --git a/packages/floating-ui-svelte/src/index.ts b/packages/floating-ui-svelte/src/index.ts index 8ad4e755..7c035a6a 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -40,6 +40,7 @@ export * from "./hooks/use-interactions.svelte.js"; export * from "./hooks/use-role.svelte.js"; export * from "./hooks/use-transition.svelte.js"; export * from "./hooks/use-list-navigation.svelte.js"; +export * from "./hooks/use-typeahead.svelte.js"; export * from "./safe-polygon.js"; /** diff --git a/packages/floating-ui-svelte/src/internal/create-pub-sub.ts b/packages/floating-ui-svelte/src/internal/create-pub-sub.ts index dc5ec185..ee91e983 100644 --- a/packages/floating-ui-svelte/src/internal/create-pub-sub.ts +++ b/packages/floating-ui-svelte/src/internal/create-pub-sub.ts @@ -14,8 +14,12 @@ function createPubSub() { // biome-ignore lint/suspicious/noExplicitAny: TODO: Type this with the actual structures? on(event: string, listener: (data: any) => void) { map.set(event, [...(map.get(event) || []), listener]); + + return () => { + map.set(event, map.get(event)?.filter((l) => l !== listener) || []); + }; }, - // biome-ignore lint/suspicious/noExplicitAny: TODO: Type this with the actual structures? + // biome-ignore lint/suspicious/noExplicitAny: TODO: Type this with the actual structures maybe not since people could make their own custom ones? Idk we'll see off(event: string, listener: (data: any) => void) { map.set(event, map.get(event)?.filter((l) => l !== listener) || []); }, diff --git a/packages/floating-ui-svelte/src/safe-polygon.ts b/packages/floating-ui-svelte/src/safe-polygon.ts index b2c6a717..48fd9dd8 100644 --- a/packages/floating-ui-svelte/src/safe-polygon.ts +++ b/packages/floating-ui-svelte/src/safe-polygon.ts @@ -80,7 +80,7 @@ function safePolygon(options: SafePolygonOptions = {}) { } const fn: HandleCloseFn = (context) => { - return function onMouseMove(event: MouseEvent) { + return (event: MouseEvent) => { function close() { clearTimeout(timeoutId); context.onClose(); @@ -118,11 +118,10 @@ function safePolygon(options: SafePolygonOptions = {}) { const bottom = (isFloatingTaller ? refRect : rect).bottom; if (isOverFloatingEl) { + console.log("isOverflowingel"); hasLanded = true; - if (!isLeave) { - return; - } + if (!isLeave) return; } if (isOverReferenceEl) { @@ -164,6 +163,7 @@ function safePolygon(options: SafePolygonOptions = {}) { (side === "left" && context.x >= refRect.right - 1) || (side === "right" && context.x <= refRect.left + 1) ) { + console.log("1"); return close(); } @@ -371,13 +371,16 @@ function safePolygon(options: SafePolygonOptions = {}) { } if (hasLanded && !isOverReferenceRect) { + console.log("2"); return close(); } if (!isLeave && requireIntent) { const cursorSpeed = getCursorSpeed(event.clientX, event.clientY); const cursorSpeedThreshold = 0.1; + console.log(cursorSpeed); if (cursorSpeed !== null && cursorSpeed < cursorSpeedThreshold) { + console.log("3"); return close(); } } @@ -390,7 +393,11 @@ function safePolygon(options: SafePolygonOptions = {}) { ) { close(); } else if (!hasLanded && requireIntent) { - timeoutId = window.setTimeout(close, 40); + function here() { + console.log("4"); + close(); + } + timeoutId = window.setTimeout(here, 40); } }; }; diff --git a/packages/floating-ui-svelte/src/types.ts b/packages/floating-ui-svelte/src/types.ts index d1bb2b64..0852ff80 100644 --- a/packages/floating-ui-svelte/src/types.ts +++ b/packages/floating-ui-svelte/src/types.ts @@ -26,8 +26,12 @@ type Rect = Coords & Dimensions; interface FloatingEvents { // biome-ignore lint/suspicious/noExplicitAny: From the port emit(event: T, data?: any): void; + /** + * Listen for events emitted by the floating tree. + * Returns a function to remove the listener. + */ // biome-ignore lint/suspicious/noExplicitAny: From the port - on(event: string, handler: (data: any) => void): void; + on(event: string, handler: (data: any) => void): () => void; // biome-ignore lint/suspicious/noExplicitAny: From the port off(event: string, handler: (data: any) => void): void; } @@ -40,6 +44,9 @@ interface ContextData { floatingContext?: FloatingContext; + /** @deprecated use `onTypingChange` prop in `useTypeahead` */ + typing?: boolean; + /** * Arbitrary data produced and consumed by other hooks. */ diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts index 1d5d31a2..e8a63995 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts @@ -1071,7 +1071,48 @@ it("grid navigation with changing list items", async () => { ); }); -it.todo("grid navigation with disabled list items"); +it("grid navigation with disabled list items after input", async () => { + render(EmojiPicker); + + await fireEvent.click(screen.getByRole("button")); + + await act(async () => {}); + + await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus()); + + await userEvent.keyboard("o"); + await userEvent.keyboard("{ArrowDown}"); + + await waitFor(() => + expect(screen.getByLabelText("orange")).not.toHaveAttribute("data-active"), + ); + await waitFor(() => + expect(screen.getByLabelText("watermelon")).toHaveAttribute("data-active"), + ); + + await userEvent.keyboard("{ArrowDown}"); + + await waitFor(() => + expect(screen.getByLabelText("watermelon")).toHaveAttribute("data-active"), + ); +}); + +it("grid navigation with disabled list items", async () => { + render(EmojiPicker); + + await fireEvent.click(screen.getByRole("button")); + + await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus()); + + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_RIGHT); + await userEvent.keyboard(testKbd.ARROW_UP); + + await waitFor(() => + expect(screen.getByLabelText("cherry")).toHaveAttribute("data-active"), + ); +}); it.todo("selectedIndex changing does not steal focus"); diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts new file mode 100644 index 00000000..96e396d0 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts @@ -0,0 +1,135 @@ +import { act, render, screen, waitFor } from "@testing-library/svelte"; +import { expect, it, vi } from "vitest"; +import Combobox from "./wrapper-components/use-typeahead/typeahead-combobox.svelte"; +import Full from "./wrapper-components/use-typeahead/typeahead-full.svelte"; +import { userEvent } from "@testing-library/user-event"; +import { sleep, testKbd } from "../../utils.js"; + +vi.useFakeTimers({ shouldAdvanceTime: true }); + +it("rapidly focuses list items when they start with the same letter", async () => { + const spy = vi.fn(); + render(Combobox, { onMatch: spy }); + + await userEvent.click(screen.getByRole("combobox")); + + await userEvent.keyboard("t"); + expect(spy).toHaveBeenCalledWith(1); + + await userEvent.keyboard("t"); + expect(spy).toHaveBeenCalledWith(2); + + await userEvent.keyboard("t"); + expect(spy).toHaveBeenCalledWith(1); +}); + +it("bails out of rapid focus of first letter if the list contains a string that starts with two of the same letter", async () => { + const spy = vi.fn(); + render(Combobox, { onMatch: spy, list: ["apple", "aaron", "apricot"] }); + + await userEvent.click(screen.getByRole("combobox")); + + await userEvent.keyboard("a"); + expect(spy).toHaveBeenCalledWith(0); + + await userEvent.keyboard("a"); + expect(spy).toHaveBeenCalledWith(0); +}); + +it("starts from the current activeIndex and correctly loops", async () => { + const spy = vi.fn(); + render(Combobox, { + onMatch: spy, + list: ["Toy Story 2", "Toy Story 3", "Toy Story 4"], + }); + + await userEvent.click(screen.getByRole("combobox")); + + await userEvent.keyboard("t"); + await userEvent.keyboard("o"); + await userEvent.keyboard("y"); + expect(spy).toHaveBeenCalledWith(0); + + spy.mockReset(); + + await userEvent.keyboard("t"); + await userEvent.keyboard("o"); + await userEvent.keyboard("y"); + expect(spy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(750); + + await userEvent.keyboard("t"); + await userEvent.keyboard("o"); + await userEvent.keyboard("y"); + expect(spy).toHaveBeenCalledWith(1); + + vi.advanceTimersByTime(750); + + await userEvent.keyboard("t"); + await userEvent.keyboard("o"); + await userEvent.keyboard("y"); + expect(spy).toHaveBeenCalledWith(2); + + vi.advanceTimersByTime(750); + + await userEvent.keyboard("t"); + await userEvent.keyboard("o"); + await userEvent.keyboard("y"); + expect(spy).toHaveBeenCalledWith(0); +}); + +it("should match capslock characters", async () => { + const spy = vi.fn(); + render(Combobox, { onMatch: spy }); + + await userEvent.click(screen.getByRole("combobox")); + + await userEvent.keyboard(`${testKbd.CAPS_LOCK}t`); + expect(spy).toHaveBeenCalledWith(1); +}); + +const oneTwoThree = ["one", "two", "three"]; + +it("matches when focus is within reference", async () => { + const spy = vi.fn(); + render(Full, { onMatch: spy, list: oneTwoThree }); + + await userEvent.click(screen.getByRole("combobox")); + + await userEvent.keyboard("t"); + expect(spy).toHaveBeenCalledWith(1); +}); + +it("matches when focus is within floating", async () => { + const spy = vi.fn(); + render(Full, { onMatch: spy, list: oneTwoThree }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("t"); + + const option = await screen.findByRole("option", { selected: true }); + expect(option.textContent).toBe("two"); + option.focus(); + expect(option).toHaveFocus(); + + await userEvent.keyboard("h"); + expect( + (await screen.findByRole("option", { selected: true })).textContent, + ).toBe("three"); +}); + +it("calls onTypingChange when typing starts or stops", async () => { + const spy = vi.fn(); + render(Combobox, { onTypingChange: spy, list: oneTwoThree }); + + await act(() => screen.getByRole("combobox").focus()); + + await userEvent.keyboard("t"); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(true); + + vi.advanceTimersByTime(750); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith(false); +}); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/impl.svelte.ts b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/impl.svelte.ts new file mode 100644 index 00000000..21037982 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/impl.svelte.ts @@ -0,0 +1,64 @@ +import type { + HTMLAttributeAnchorTarget, + HTMLAttributes, +} from "svelte/elements"; +import { + useClick, + useFloating, + useInteractions, + useTypeahead, + type UseTypeaheadOptions, +} from "../../../../../src/index.js"; +import type { Getter } from "../../../../../src/types.js"; + +export function useImpl({ + addUseClick = false, + ...props +}: Pick & { + list?: Array; + open?: Getter; + onOpenChange?: (open: boolean) => void; + addUseClick?: boolean; +}) { + let open = $state(true); + let activeIndex = $state(null); + const f = useFloating({ + open: () => props.open?.() ?? open, + onOpenChange: props.onOpenChange ?? ((o) => (open = o)), + }); + const list = props.list ?? ["one", "two", "three"]; + + const typeahead = useTypeahead(f.context, { + listRef: list, + activeIndex: () => activeIndex, + onMatch: (idx) => { + activeIndex = idx; + props.onMatch?.(idx); + }, + onTypingChange: props.onTypingChange, + }); + const click = useClick(f.context, { + enabled: addUseClick, + }); + + const ints = useInteractions([typeahead, click]); + + return { + floating: f, + get activeIndex() { + return activeIndex; + }, + get open() { + return open; + }, + getReferenceProps: (userProps?: HTMLAttributes) => + ints.getReferenceProps({ + role: "combobox", + ...userProps, + }), + getFloatingProps: () => + ints.getFloatingProps({ + role: "listbox", + }), + }; +} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-combobox.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-combobox.svelte new file mode 100644 index 00000000..2bb91461 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-combobox.svelte @@ -0,0 +1,13 @@ + + + +
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-full.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-full.svelte new file mode 100644 index 00000000..cdbe8962 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-full.svelte @@ -0,0 +1,29 @@ + + +
    input.focus(), + })}> + +
    +{#if impl.open} +
    + {#each props.list as value, i (value)} +
    + {value} +
    + {/each} +
    +{/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-select.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-select.svelte new file mode 100644 index 00000000..3bad1d8e --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-select.svelte @@ -0,0 +1,26 @@ + + + +
    +
    +{#if open} +
    +{/if} diff --git a/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte b/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte index 342a7195..9c90a43c 100644 --- a/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte @@ -51,10 +51,7 @@ useRole, } from "../../../../src/index.js"; import { arrow, autoUpdate, flip, offset } from "@floating-ui/dom"; - import type { - FormEventHandler, - KeyboardEventHandler, - } from "svelte/elements"; + import type { KeyboardEventHandler } from "svelte/elements"; import Button from "../button.svelte"; import FloatingPortal from "../../../../src/components/floating-portal/floating-portal.svelte"; import FloatingFocusManager from "../../../../src/components/floating-focus-manager/floating-focus-manager.svelte"; diff --git a/packages/floating-ui-svelte/test/visual/components/listbox-focus/context.ts b/packages/floating-ui-svelte/test/visual/components/listbox-focus/context.ts new file mode 100644 index 00000000..a655cbfb --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/listbox-focus/context.ts @@ -0,0 +1,11 @@ +import type { useInteractions } from "../../../../src/index.js"; +import { Context } from "../../../../src/internal/context.js"; + +interface SelectContextValue { + activeIndex: number | null; + selectedIndex: number | null; + getItemProps: ReturnType["getItemProps"]; + handleSelect: (index: number | null) => void; +} + +export const SelectContext = new Context("SelectContext"); diff --git a/packages/floating-ui-svelte/test/visual/components/listbox-focus/listbox.svelte b/packages/floating-ui-svelte/test/visual/components/listbox-focus/listbox.svelte new file mode 100644 index 00000000..04cede5a --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/listbox-focus/listbox.svelte @@ -0,0 +1,32 @@ + diff --git a/packages/floating-ui-svelte/test/visual/components/menu/context.ts b/packages/floating-ui-svelte/test/visual/components/menu/context.ts new file mode 100644 index 00000000..0c2c2ba2 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/context.ts @@ -0,0 +1,14 @@ +import type { Snippet } from "svelte"; +import type { useInteractions } from "../../../../src/index.js"; +import { Context } from "../../../../src/internal/context.js"; + +export type MenuContextType = { + getItemProps: ReturnType["getItemProps"]; + activeIndex: number | null; + setHasFocusInside: (curr: boolean) => void; + allowHover: boolean; + open: boolean; + parent: MenuContextType | null; +}; + +export const MenuContext = new Context("MenuContext"); diff --git a/packages/floating-ui-svelte/test/visual/components/menu/main.svelte b/packages/floating-ui-svelte/test/visual/components/menu/main.svelte new file mode 100644 index 00000000..fbf97c65 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/main.svelte @@ -0,0 +1,29 @@ + + +

    Menu

    +
    + + console.log("Undo")} /> + + + + + + + + + + + + + + + + + + +
    diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu-context-provider.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu-context-provider.svelte new file mode 100644 index 00000000..ec791349 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-context-provider.svelte @@ -0,0 +1,24 @@ + + + + +{@render props.children?.()} diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte new file mode 100644 index 00000000..8707823c --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte @@ -0,0 +1,283 @@ + + + + + ints.getItemProps(u), + setHasFocusInside: (v: boolean) => { + hasFocusInside = v; + }, + get allowHover() { + return allowHover; + }, + get open() { + return open; + }, + set open(v: boolean) { + open = v; + }, + parent, + }}> + + {#if forceMount || open} + + +
    + {@render children?.()} +
    +
    +
    + {/if} +
    +
    +
    diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu-item.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu-item.svelte new file mode 100644 index 00000000..0b044f9a --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-item.svelte @@ -0,0 +1,81 @@ + + + diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte new file mode 100644 index 00000000..ab98e833 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte @@ -0,0 +1,19 @@ + + +{#if parentId === null} + + + +{:else} + +{/if} diff --git a/packages/floating-ui-svelte/test/visual/components/menu/types.ts b/packages/floating-ui-svelte/test/visual/components/menu/types.ts new file mode 100644 index 00000000..0f18ada1 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/types.ts @@ -0,0 +1,8 @@ +import type { Snippet } from "svelte"; + +export interface MenuProps { + label: string; + nested?: boolean; + children?: Snippet; + forceMount?: boolean; +} diff --git a/packages/floating-ui-svelte/test/visual/routes/menu/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/menu/+page.svelte new file mode 100644 index 00000000..5f098ba5 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/menu/+page.svelte @@ -0,0 +1,5 @@ + + +
    From 2362cd160eb686aaea5bc65975904f9b9a22bab1 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 22 Jan 2025 11:12:06 -0500 Subject: [PATCH 57/87] fix some stuff --- .../src/hooks/use-hover.svelte.ts | 6 ++--- .../src/hooks/use-typeahead.svelte.ts | 23 +++++++++++-------- .../src/internal/snapshot.svelte.ts | 1 + .../floating-ui-svelte/src/safe-polygon.ts | 7 +++--- .../test/visual/components/menu/main.svelte | 12 ++++++++-- .../visual/components/menu/menu-impl.svelte | 4 +++- .../test/visual/components/menu/menu.svelte | 3 ++- 7 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index fbd35cf0..64262dc7 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -22,10 +22,8 @@ import { useFloatingTree, } from "../components/floating-tree/hooks.svelte.js"; import { on } from "svelte/events"; -import { executeCallbacks } from "../internal/execute-callbacks.js"; import { snapshotFloatingContext } from "../internal/snapshot.svelte.js"; import { watch } from "../internal/watch.svelte.js"; -import type { ElementProps } from "./use-interactions.svelte.js"; import { extract } from "../internal/extract.js"; interface DelayOptions { @@ -236,7 +234,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { () => isHoverOpen, ], () => { - if (!enabled || !handleClose || !open) return; + if (!enabled || !handleClose || !context.open) return; const onLeave = (event: MouseEvent) => { if (!isHoverOpen) return; @@ -260,7 +258,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { () => mouseOnly, () => restMs, () => move, - () => open, + () => context.open, () => tree?.nodes, () => delay, () => context.data.floatingContext, diff --git a/packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts index 524588e5..35df94b0 100644 --- a/packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts @@ -82,21 +82,24 @@ function useTypeahead( let matchIndex: number | null = null; watch.pre( - () => open, + () => context.open, () => { - if (!open) return; + if (!context.open) return; clearTimeout(timeoutId); matchIndex = null; str = ""; }, ); - watch.pre([() => open, () => selectedIndex, () => activeIndex], () => { - // sync arrow key nav but not typeahead nav - if (context.open && str === "") { - prevIndex = selectedIndex ?? activeIndex ?? -1; - } - }); + watch.pre( + [() => context.open, () => selectedIndex, () => activeIndex], + () => { + // sync arrow key nav but not typeahead nav + if (context.open && str === "") { + prevIndex = selectedIndex ?? activeIndex ?? -1; + } + }, + ); function setTypingChange(value: boolean) { if (value) { @@ -129,7 +132,7 @@ function useTypeahead( function onkeydown(event: KeyboardEvent) { const listContent = listRef; - const open = context.open; + const isOpen = context.open; if (str.length > 0 && str[0] !== " ") { if (getMatchingIndex(listContent, listContent, str) === -1) { @@ -152,7 +155,7 @@ function useTypeahead( return; } - if (open && event.key !== " ") { + if (isOpen && event.key !== " ") { stopEvent(event); setTypingChange(true); } diff --git a/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts b/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts index fdfebe43..bc76a0d8 100644 --- a/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts +++ b/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts @@ -30,6 +30,7 @@ function snapshotFloatingContext( floatingId: context.floatingId, events: context.events, nodeId: context.nodeId, + setPositionReference: context.setPositionReference, }; }, }; diff --git a/packages/floating-ui-svelte/src/safe-polygon.ts b/packages/floating-ui-svelte/src/safe-polygon.ts index 48fd9dd8..041f8ae5 100644 --- a/packages/floating-ui-svelte/src/safe-polygon.ts +++ b/packages/floating-ui-svelte/src/safe-polygon.ts @@ -102,6 +102,7 @@ function safePolygon(options: SafePolygonOptions = {}) { const clientPoint: Point = [clientX, clientY]; const target = getTarget(event) as Element | null; const isLeave = event.type === "mouseleave"; + console.log(event.type); const isOverFloatingEl = contains(context.floating, target); const isOverReferenceEl = contains(context.domReference, target); const refRect = context.domReference.getBoundingClientRect(); @@ -118,7 +119,6 @@ function safePolygon(options: SafePolygonOptions = {}) { const bottom = (isFloatingTaller ? refRect : rect).bottom; if (isOverFloatingEl) { - console.log("isOverflowingel"); hasLanded = true; if (!isLeave) return; @@ -378,9 +378,7 @@ function safePolygon(options: SafePolygonOptions = {}) { if (!isLeave && requireIntent) { const cursorSpeed = getCursorSpeed(event.clientX, event.clientY); const cursorSpeedThreshold = 0.1; - console.log(cursorSpeed); if (cursorSpeed !== null && cursorSpeed < cursorSpeedThreshold) { - console.log("3"); return close(); } } @@ -391,10 +389,11 @@ function safePolygon(options: SafePolygonOptions = {}) { getPolygon([context.x, context.y]), ) ) { + console.log("4"); close(); } else if (!hasLanded && requireIntent) { function here() { - console.log("4"); + console.log("5"); close(); } timeoutId = window.setTimeout(here, 40); diff --git a/packages/floating-ui-svelte/test/visual/components/menu/main.svelte b/packages/floating-ui-svelte/test/visual/components/menu/main.svelte index fbf97c65..ebcab557 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/main.svelte @@ -10,10 +10,18 @@ console.log("Undo")} /> - + - + diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte index 8707823c..c56872ff 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte @@ -43,10 +43,11 @@ forceMount = false, ref = $bindable(null), class: className, + floatingId, ...rest }: MenuProps & Partial> & - HTMLButtonAttributes = $props(); + HTMLButtonAttributes & { floatingId?: string } = $props(); let open = $state(false); let activeIndex = $state(null); @@ -261,6 +262,7 @@ initialFocus={isNested ? -1 : 0} returnFocus={!isNested}>
    From bbce13e20938b691ea7011f779127a465eeb2188 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 22 Jan 2025 11:59:21 -0500 Subject: [PATCH 58/87] polygon debugger --- .../src/hooks/use-hover.svelte.ts | 42 +++++++++---------- .../floating-ui-svelte/src/safe-polygon.ts | 8 +++- .../visual/components/menu/menu-impl.svelte | 5 ++- .../test/visual/components/menu/menu.svelte | 3 ++ .../components/utils/debug-polygon.svelte | 29 +++++++++++++ .../utils/debug-polygon.svelte.d.ts | 7 ++++ 6 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 packages/floating-ui-svelte/test/visual/components/utils/debug-polygon.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/utils/debug-polygon.svelte.d.ts diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index 64262dc7..5fa537d9 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -118,6 +118,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { const handleClose: HandleCloseFn | null = options.handleClose ?? null; const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); + let pointerType: PointerType | undefined = undefined; let timeout = -1; let handler: ((event: MouseEvent) => void) | undefined = undefined; @@ -207,7 +208,9 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { closeWithDelay(event, false); } - watch([() => enabled, () => context.events], ([enabled, events]) => { + // When closing before opening, clear the delay timeouts to cancel it + // from showing. + watch([() => enabled, () => context.events], () => { if (!enabled) return; const onOpenChange = ({ open }: { open: boolean }) => { @@ -219,11 +222,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { } }; - events.on("openchange", onOpenChange); - - return () => { - events.off("openchange", onOpenChange); - }; + return context.events.on("openchange", onOpenChange); }); watch( @@ -234,12 +233,14 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { () => isHoverOpen, ], () => { - if (!enabled || !handleClose || !context.open) return; + if (!enabled) return; + if (!handleClose) return; + if (!context.open) return; - const onLeave = (event: MouseEvent) => { + function onLeave(event: MouseEvent) { if (!isHoverOpen) return; context.onOpenChange(false, event, "hover"); - }; + } const html = getDocument(context.floating).documentElement; return on(html, "mouseleave", onLeave); @@ -267,10 +268,9 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { () => { if (!enabled) return; - const onMouseEnter = (event: MouseEvent) => { + function onMouseEnter(event: MouseEvent) { window.clearTimeout(timeout); blockMouseMove = false; - const isOpen = context.open; if ( (mouseOnly && !isMouseLikePointerType(pointerType)) || @@ -280,6 +280,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { } const openDelay = getDelay(delay, "open", pointerType); + const isOpen = context.open; if (openDelay) { timeout = window.setTimeout(() => { @@ -287,13 +288,12 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { context.onOpenChange(true, event, "hover"); } }, openDelay); - } else if (!isOpen) { + } else if (!context.open) { context.onOpenChange(true, event, "hover"); } - }; + } - const onMouseLeave = (event: MouseEvent) => { - console.log("mouse leave"); + function onMouseLeave(event: MouseEvent) { if (isClickLikeOpenEvent) return; const isOpen = context.open; @@ -344,7 +344,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { if (shouldClose) { closeWithDelay(event); } - }; + } // Ensure the floating element closes after scrolling even if the pointer // did not move. @@ -355,7 +355,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { handleClose?.({ ...snapshotFloatingContext(context.data.floatingContext).current, - tree: tree, + tree, x: event.clientX, y: event.clientY, onClose: () => { @@ -367,16 +367,16 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { }, })(event); }; - const isOpen = context.open; if (isElement(context.domReference)) { const ref = context.domReference as unknown as HTMLElement; - isOpen && ref.addEventListener("mouseleave", onScrollMouseLeave); + context.open && ref.addEventListener("mouseleave", onScrollMouseLeave); context.floating?.addEventListener("mouseleave", onScrollMouseLeave); move && ref.addEventListener("mousemove", onMouseEnter, { once: true }); ref.addEventListener("mouseenter", onMouseEnter); ref.addEventListener("mouseleave", onMouseLeave); return () => { - isOpen && ref.removeEventListener("mouseleave", onScrollMouseLeave); + context.open && + ref.removeEventListener("mouseleave", onScrollMouseLeave); context.floating?.removeEventListener( "mouseleave", onScrollMouseLeave, @@ -442,7 +442,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { }, ); - $effect(() => { + $effect.pre(() => { if (!context.open) { pointerType = undefined; restTimeoutPending = false; diff --git a/packages/floating-ui-svelte/src/safe-polygon.ts b/packages/floating-ui-svelte/src/safe-polygon.ts index 041f8ae5..eea767c5 100644 --- a/packages/floating-ui-svelte/src/safe-polygon.ts +++ b/packages/floating-ui-svelte/src/safe-polygon.ts @@ -3,6 +3,7 @@ import type { Rect, Side } from "./types.js"; import type { HandleCloseFn } from "./hooks/use-hover.svelte.js"; import { contains, getTarget } from "./internal/dom.js"; import { getChildren } from "./internal/get-children.js"; +import { debugPolygon } from "../test/visual/components/utils/debug-polygon.svelte"; type Point = [number, number]; type Polygon = Point[]; @@ -102,15 +103,16 @@ function safePolygon(options: SafePolygonOptions = {}) { const clientPoint: Point = [clientX, clientY]; const target = getTarget(event) as Element | null; const isLeave = event.type === "mouseleave"; - console.log(event.type); const isOverFloatingEl = contains(context.floating, target); const isOverReferenceEl = contains(context.domReference, target); + const refRect = context.domReference.getBoundingClientRect(); const rect = context.floating.getBoundingClientRect(); const side = context.placement.split("-")[0] as Side; const cursorLeaveFromRight = context.x > rect.right - rect.width / 2; const cursorLeaveFromBottom = context.y > rect.bottom - rect.height / 2; const isOverReferenceRect = isInside(clientPoint, refRect); + console.log("isOverReferenceRect", isOverReferenceRect); const isFloatingWider = rect.width > refRect.width; const isFloatingTaller = rect.height > refRect.height; const left = (isFloatingWider ? refRect : rect).left; @@ -366,6 +368,10 @@ function safePolygon(options: SafePolygonOptions = {}) { } } + const polygon = getPolygon([context.x, context.y]); + debugPolygon.current.tri = polygon; + debugPolygon.current.rect = rectPoly; + if (isPointInPolygon([clientX, clientY], rectPoly)) { return; } diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte index c56872ff..7322d785 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte @@ -262,7 +262,6 @@ initialFocus={isNested ? -1 : 0} returnFocus={!isNested}>
    + {...ints.getFloatingProps({ + id: floatingId, + })}> {@render children?.()}
    diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte index 1ff45fe6..de3df96a 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte @@ -4,6 +4,7 @@ import { useFloatingParentNodeId } from "../../../../src/index.js"; import FloatingTree from "../../../../src/components/floating-tree/floating-tree.svelte"; import MenuImpl from "./menu-impl.svelte"; + import DebugPolygon from "../../utils/debug-polygon.svelte"; let props: MenuProps & HTMLButtonAttributes & { floatingId?: string } = $props(); @@ -18,3 +19,5 @@ {:else} {/if} + + diff --git a/packages/floating-ui-svelte/test/visual/components/utils/debug-polygon.svelte b/packages/floating-ui-svelte/test/visual/components/utils/debug-polygon.svelte new file mode 100644 index 00000000..4833935d --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/utils/debug-polygon.svelte @@ -0,0 +1,29 @@ + + + + +{#if debugPolygon.current.rect.length && debugPolygon.current.tri.length} + + {#each paths as { d, fill }} + + {/each} + +{/if} diff --git a/packages/floating-ui-svelte/test/visual/components/utils/debug-polygon.svelte.d.ts b/packages/floating-ui-svelte/test/visual/components/utils/debug-polygon.svelte.d.ts new file mode 100644 index 00000000..86854435 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/utils/debug-polygon.svelte.d.ts @@ -0,0 +1,7 @@ +export declare const debugPolygon: import("../../../../src/internal/box.svelte.js").WritableBox<{ + rect: [number, number][]; + tri: [number, number][]; +}>; +declare const DebugPolygon: import("svelte").Component, {}, "">; +type DebugPolygon = ReturnType; +export default DebugPolygon; From 41e877539d12b1682f2a33085aab4b9ef6a7572d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 22 Jan 2025 21:12:23 -0500 Subject: [PATCH 59/87] lets try something different here --- biome.json | 3 + .../src/hooks/use-click.svelte.ts | 15 +- .../hooks/use-floating-root-context.svelte.ts | 9 +- .../src/hooks/use-floating.svelte.ts | 11 + .../src/hooks/use-hover.svelte.ts | 303 ++++++++-------- .../src/internal/use-grace-area.svelte.ts | 331 ++++++++++++++++++ .../floating-ui-svelte/src/safe-polygon.ts | 54 +-- .../test/visual/components/menu/main.svelte | 2 +- .../visual/components/menu/menu-impl.svelte | 22 +- .../test/visual/components/menu/menu.svelte | 4 +- 10 files changed, 557 insertions(+), 197 deletions(-) create mode 100644 packages/floating-ui-svelte/src/internal/use-grace-area.svelte.ts diff --git a/biome.json b/biome.json index 09ca098d..fef776a3 100644 --- a/biome.json +++ b/biome.json @@ -22,6 +22,9 @@ "include": ["*.ts"], "linter": { "rules": { + "style": { + "noNonNullAssertion": "off" + }, "suspicious": { "noAssignInExpressions": "off" } diff --git a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts index d27dd6a8..e9eb5e4a 100644 --- a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts @@ -173,15 +173,12 @@ function useClick( } } - const reference = $derived.by(() => { - if (!enabled) return {}; - return { - onpointerdown: onpointerdown, - onmousedown: onmousedown, - onclick: onclick, - onkeydown: onkeydown, - onkeyup: onkeyup, - }; + const reference = $derived({ + onpointerdown: onpointerdown, + onmousedown: onmousedown, + onclick: onclick, + onkeydown: onkeydown, + onkeyup: onkeyup, }); return { diff --git a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts index 8a1ff74b..18d8980f 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts @@ -27,6 +27,12 @@ interface UseFloatingRootContextOptions { floating: MaybeGetter; onReferenceChange?: (node: Element | null) => void; onFloatingChange?: (node: HTMLElement | null) => void; + /** + * The id to assign to the floating element. + * + * @default useId() + */ + floatingId?: MaybeGetter; } class FloatingRootContextOptions { @@ -40,6 +46,7 @@ class FloatingRootContextOptions { floating: WritableBox; floatingProp = $derived.by(() => extract(this.options.floating, null)); referenceProp = $derived.by(() => extract(this.options.reference, null)); + floatingId = $derived.by(() => extract(this.options.floatingId, useId())); constructor(readonly options: UseFloatingRootContextOptions) { this.open = box.with(() => extract(options.open, false)); @@ -75,7 +82,7 @@ class FloatingRootContextOptions { } class FloatingRootContext { - floatingId = useId(); + floatingId = $derived.by(() => extract(this.options.floatingId) ?? useId()); data: ContextData = $state({}); events = createPubSub(); open = $derived.by(() => this.options.open.current); diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index a2e2745e..ed76134d 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -25,6 +25,7 @@ import { import { extract } from "../internal/extract.js"; import { noop } from "../internal/noop.js"; import { isElement } from "@floating-ui/utils/dom"; +import { useId } from "bits-ui"; interface UseFloatingOptions { /** @@ -92,6 +93,13 @@ interface UseFloatingOptions { * Unique node id when using `FloatingTree`. */ nodeId?: MaybeGetter; + + /** + * A unique id for the floating element. + * + * @default useId() + */ + floatingId?: MaybeGetter; } /** @@ -109,6 +117,7 @@ class FloatingOptions { transform: ReadableBox; whileElementsMounted: WhileElementsMounted | undefined; rootContext: ReadableBox | undefined>; + floatingId: ReadableBox; onReferenceChange: (node: Element | null) => void; onFloatingChange: (node: HTMLElement | null) => void; onOpenChange: ( @@ -134,6 +143,7 @@ class FloatingOptions { this.strategy = box.with(() => extract(options.strategy, "absolute")); this.middleware = box.with(() => extract(options.middleware, [])); this.transform = box.with(() => extract(options.transform, true)); + this.floatingId = box.with(() => extract(options.floatingId) ?? useId()); this.onOpenChange = options.onOpenChange ?? noop; this.onReferenceChange = options.onReferenceChange ?? noop; this.onFloatingChange = options.onFloatingChange ?? noop; @@ -296,6 +306,7 @@ class FloatingState { reference: () => options.reference.current, floating: () => options.floating.current, onOpenChange: options.onOpenChange, + floatingId: () => options.floatingId.current, }); this.#rootContext = diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index 5fa537d9..3290b4ba 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -25,6 +25,8 @@ import { on } from "svelte/events"; import { snapshotFloatingContext } from "../internal/snapshot.svelte.js"; import { watch } from "../internal/watch.svelte.js"; import { extract } from "../internal/extract.js"; +import type { ElementProps } from "./use-interactions.svelte.js"; +import { sleep } from "../internal/sleep.js"; interface DelayOptions { /** @@ -127,15 +129,12 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { let performedPointerEventsMutation = false; let unbindMouseMove = noop; let restTimeoutPending = false; - const isHoverOpen = $derived.by(() => { + + function isHoverOpen(): boolean { const type = context.data.openEvent?.type; - return type?.includes("mouse") && type !== "mousedown"; - }); - const isClickLikeOpenEvent = $derived.by(() => { - return context.data.openEvent - ? ["click", "mousedown"].includes(context.data.openEvent.type) - : false; - }); + if (!type) return false; + return type.includes("mouse") && type !== "mousedown"; + } function closeWithDelay( event: Event, @@ -175,11 +174,11 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { } function onReferenceMouseMove(event: MouseEvent) { - const handleMouseMove = () => { + function handleMouseMove() { if (!blockMouseMove && !context.open) { context.onOpenChange(true, event, "hover"); } - }; + } if (mouseOnly && !isMouseLikePointerType(pointerType)) return; if (context.open || restMs === 0) return; @@ -203,8 +202,14 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { window.clearTimeout(timeout); } + function isClickLikeOpenEvent(): boolean { + return context.data.openEvent + ? ["click", "mousedown"].includes(context.data.openEvent.type) + : false; + } + function onFloatingMouseLeave(event: MouseEvent) { - if (isClickLikeOpenEvent) return; + if (isClickLikeOpenEvent()) return; closeWithDelay(event, false); } @@ -225,27 +230,21 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { return context.events.on("openchange", onOpenChange); }); - watch( - [ - () => enabled, - () => context.open, - () => context.floating, - () => isHoverOpen, - ], - () => { - if (!enabled) return; - if (!handleClose) return; - if (!context.open) return; + watch([() => enabled, () => context.open, () => context.floating], () => { + if (!enabled) return; + if (!handleClose) return; + if (!context.open) return; + if (!context.floating) return; - function onLeave(event: MouseEvent) { - if (!isHoverOpen) return; - context.onOpenChange(false, event, "hover"); - } + function onLeave(event: MouseEvent) { + if (!isHoverOpen()) return; - const html = getDocument(context.floating).documentElement; - return on(html, "mouseleave", onLeave); - }, - ); + context.onOpenChange(false, event, "hover"); + } + + const html = getDocument(context.floating).documentElement; + return on(html, "mouseleave", onLeave); + }); // Registering the mouse events on the reference directly to bypass Svelte's // delegation system. If the cursor was on a disabled element and then entered @@ -263,127 +262,18 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { () => tree?.nodes, () => delay, () => context.data.floatingContext, - () => isClickLikeOpenEvent, + () => isClickLikeOpenEvent(), ], () => { if (!enabled) return; + const localMove = move; - function onMouseEnter(event: MouseEvent) { - window.clearTimeout(timeout); - blockMouseMove = false; - - if ( - (mouseOnly && !isMouseLikePointerType(pointerType)) || - (restMs > 0 && !getDelay(delay, "open")) - ) { - return; - } - - const openDelay = getDelay(delay, "open", pointerType); - const isOpen = context.open; - - if (openDelay) { - timeout = window.setTimeout(() => { - if (!isOpen) { - context.onOpenChange(true, event, "hover"); - } - }, openDelay); - } else if (!context.open) { - context.onOpenChange(true, event, "hover"); - } - } - - function onMouseLeave(event: MouseEvent) { - if (isClickLikeOpenEvent) return; - const isOpen = context.open; - - unbindMouseMove(); - - const doc = getDocument(context.floating); - window.clearTimeout(restTimeout); - restTimeoutPending = false; - - if (handleClose && context.data.floatingContext) { - // Prevent clearing `onScrollMouseLeave` timeout. - if (!isOpen) { - window.clearTimeout(timeout); - } - - handler = handleClose({ - ...snapshotFloatingContext(context).current, - tree: tree, - x: event.clientX, - y: event.clientY, - onClose: () => { - clearPointerEvents(); - cleanupMouseMoveHandler(); - if (!isClickLikeOpenEvent) { - closeWithDelay(event, true, "safe-polygon"); - } - }, - }); - - const localHandler = handler; - - doc.addEventListener("mousemove", localHandler); - - unbindMouseMove = () => { - doc.removeEventListener("mousemove", localHandler); - }; - - return; - } - - // Allow interactivity without `safePolygon` on touch devices. With a - // pointer, a short close delay is an alternative, so it should work - // consistently. - const shouldClose = - pointerType === "touch" - ? !contains(context.floating, event.relatedTarget as Element | null) - : true; - if (shouldClose) { - closeWithDelay(event); - } - } - - // Ensure the floating element closes after scrolling even if the pointer - // did not move. - // https://github.com/floating-ui/floating-ui/discussions/1692 - const onScrollMouseLeave = (event: MouseEvent) => { - if (isClickLikeOpenEvent) return; - if (!context.data.floatingContext) return; - - handleClose?.({ - ...snapshotFloatingContext(context.data.floatingContext).current, - tree, - x: event.clientX, - y: event.clientY, - onClose: () => { - clearPointerEvents(); - cleanupMouseMoveHandler(); - if (!isClickLikeOpenEvent) { - closeWithDelay(event); - } - }, - })(event); - }; if (isElement(context.domReference)) { const ref = context.domReference as unknown as HTMLElement; - context.open && ref.addEventListener("mouseleave", onScrollMouseLeave); - context.floating?.addEventListener("mouseleave", onScrollMouseLeave); - move && ref.addEventListener("mousemove", onMouseEnter, { once: true }); - ref.addEventListener("mouseenter", onMouseEnter); - ref.addEventListener("mouseleave", onMouseLeave); + localMove && + ref.addEventListener("mousemove", onMouseEnter, { once: true }); return () => { - context.open && - ref.removeEventListener("mouseleave", onScrollMouseLeave); - context.floating?.removeEventListener( - "mouseleave", - onScrollMouseLeave, - ); - move && ref.removeEventListener("mousemove", onMouseEnter); - ref.removeEventListener("mouseenter", onMouseEnter); - ref.removeEventListener("mouseleave", onMouseLeave); + localMove && ref.removeEventListener("mousemove", onMouseEnter); }; } }, @@ -400,7 +290,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { () => context.floating, () => context.domReference, () => handleClose, - () => isHoverOpen, + () => isHoverOpen(), () => tree?.nodes, ], () => { @@ -408,7 +298,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { if ( context.open && handleClose?.__options.blockPointerEvents && - isHoverOpen + isHoverOpen() ) { performedPointerEventsMutation = true; const floatingEl = context.floating; @@ -430,7 +320,6 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { body.style.pointerEvents = "none"; ref.style.pointerEvents = "auto"; - floatingEl.style.pointerEvents = "auto"; return () => { @@ -460,15 +349,129 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { }; }); - const reference = $derived({ + function onMouseEnter(event: MouseEvent) { + window.clearTimeout(timeout); + blockMouseMove = false; + + if ( + (mouseOnly && !isMouseLikePointerType(pointerType)) || + (restMs > 0 && !getDelay(delay, "open")) + ) { + return; + } + + const openDelay = getDelay(delay, "open", pointerType); + const isOpen = context.open; + + if (openDelay) { + timeout = window.setTimeout(() => { + if (!isOpen) { + context.onOpenChange(true, event, "hover"); + } + }, openDelay); + } else if (!context.open) { + context.onOpenChange(true, event, "hover"); + } + } + + function onMouseLeave(event: MouseEvent) { + if (isClickLikeOpenEvent()) return; + const isOpen = context.open; + + unbindMouseMove(); + + const doc = getDocument(context.floating); + window.clearTimeout(restTimeout); + restTimeoutPending = false; + + if (handleClose && context.data.floatingContext) { + // Prevent clearing `onScrollMouseLeave` timeout. + if (!isOpen) { + window.clearTimeout(timeout); + } + handler = handleClose({ + ...snapshotFloatingContext(context.data.floatingContext).current, + tree: tree, + x: event.clientX, + y: event.clientY, + onClose: () => { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event, true, "safe-polygon"); + } + }, + domReference: context.domReference, + floating: context.floating, + }); + + const localHandler = (e: MouseEvent) => { + if (!context.floating) return; + handler?.(e); + }; + + const remove = on(doc, "mousemove", localHandler); + unbindMouseMove(); + unbindMouseMove = remove; + + return; + } + + // Allow interactivity without `safePolygon` on touch devices. With a + // pointer, a short close delay is an alternative, so it should work + // consistently. + const shouldClose = + pointerType === "touch" + ? !contains(context.floating, event.relatedTarget as Element | null) + : true; + if (shouldClose) { + closeWithDelay(event); + } + } + + // Ensure the floating element closes after scrolling even if the pointer + // did not move. + // https://github.com/floating-ui/floating-ui/discussions/1692 + function onScrollMouseLeave(event: MouseEvent) { + if (isClickLikeOpenEvent()) return; + if (!context.data.floatingContext) return; + + handleClose?.({ + ...snapshotFloatingContext(context.data.floatingContext).current, + tree, + x: event.clientX, + y: event.clientY, + onClose: () => { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event); + } + }, + })(event); + } + + const reference: ElementProps["reference"] = $derived({ onpointerdown: setPointerType, onpointerenter: setPointerType, onmousemove: onReferenceMouseMove, + onmouseenter: onMouseEnter, + onmouseleave: (event) => { + if (context.open) { + onScrollMouseLeave(event); + } + onMouseLeave(event); + }, }); - const floating = $derived({ + const floating: ElementProps["floating"] = $derived({ onmouseenter: onFloatingMouseEnter, - onmouseleave: onFloatingMouseLeave, + onmouseleave: (event) => { + onFloatingMouseLeave(event); + if (isElement(context.domReference)) { + onScrollMouseLeave(event); + } + }, }); return { diff --git a/packages/floating-ui-svelte/src/internal/use-grace-area.svelte.ts b/packages/floating-ui-svelte/src/internal/use-grace-area.svelte.ts new file mode 100644 index 00000000..dcbd9eae --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/use-grace-area.svelte.ts @@ -0,0 +1,331 @@ +import { on } from "svelte/events"; +import { box, type WritableBox } from "./box.svelte.js"; +import { executeCallbacks } from "./execute-callbacks.js"; +import type { Getter, Side } from "../types.js"; +import { isElement, isHTMLElement } from "@floating-ui/utils/dom"; +import { watch } from "./watch.svelte.js"; + +// https://stackoverflow.com/questions/55541275/typescript-check-for-the-any-type +type IfAny = 0 extends 1 & T ? Y : N; +/** + * will return `true` if `T` is `any`, or `false` otherwise + */ +type IsAny = IfAny; + +// any extends void = true +// so we need to check if T is any first +type Callback = IsAny extends true + ? // biome-ignore lint/suspicious/noExplicitAny: + (param: any) => void + : // biome-ignore lint/suspicious/noConfusingVoidType: + [T] extends [void] + ? () => void + : (param: T) => void; + +type EventHookOn = (fn: Callback) => { off: () => void }; +type EventHookOff = (fn: Callback) => void; +type EventHookTrigger = (param?: T) => Promise; + +interface EventHook { + on: EventHookOn; + off: EventHookOff; + trigger: EventHookTrigger; +} + +function createEventHook(): EventHook { + const callbacks: Set> = new Set(); + const callbacksToDispose: Callback[] = []; + + function off(cb: Callback) { + callbacks.delete(cb); + } + + function on(cb: Callback) { + callbacks.add(cb); + const offFn = () => off(cb); + callbacksToDispose.push(offFn); + return { off: offFn }; + } + + const trigger: EventHookTrigger = (...args) => { + return Promise.all( + Array.from(callbacks).map((cb) => { + return Promise.resolve(cb(...(args as [T]))); + }), + ); + }; + + $effect(() => { + return () => { + executeCallbacks(callbacksToDispose); + }; + }); + + return { + on, + off, + trigger, + }; +} + +/** + * Creates a box which will be reset to the default value after some time. + * + * @param defaultValue The value which will be set. + * @param afterMs A zero-or-greater delay in milliseconds. + */ +function boxAutoReset(defaultValue: T, afterMs = 10000): WritableBox { + let timeout: NodeJS.Timeout | null = null; + let value = $state(defaultValue); + + function resetAfter() { + return setTimeout(() => { + value = defaultValue; + }, afterMs); + } + + $effect(() => { + return () => { + if (timeout) clearTimeout(timeout); + }; + }); + + return box.with( + () => value, + (v) => { + value = v; + if (timeout) clearTimeout(timeout); + timeout = resetAfter(); + }, + ); +} + +function useGraceArea( + getTriggerNode: Getter, + getContentNode: Getter, +) { + const isPointerInTransit = boxAutoReset(false, 300); + const triggerNode = $derived(getTriggerNode()); + const contentNode = $derived(getContentNode()); + + let pointerGraceArea = $state(null); + const pointerExit = createEventHook(); + + function handleRemoveGraceArea() { + pointerGraceArea = null; + isPointerInTransit.current = false; + } + + function handleCreateGraceArea(e: PointerEvent, hoverTarget: HTMLElement) { + const currentTarget = e.currentTarget; + if (!isHTMLElement(currentTarget)) return; + const exitPoint = { x: e.clientX, y: e.clientY }; + const exitSide = getExitSideFromRect( + exitPoint, + currentTarget.getBoundingClientRect(), + ); + const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide); + const hoverTargetPoints = getPointsFromRect( + hoverTarget.getBoundingClientRect(), + ); + const graceArea = getHull([...paddedExitPoints, ...hoverTargetPoints]); + pointerGraceArea = graceArea; + isPointerInTransit.current = true; + } + + watch( + [() => triggerNode, () => contentNode], + ([triggerNode, contentNode]) => { + if (!triggerNode || !contentNode) return; + const handleTriggerLeave = (e: PointerEvent) => { + handleCreateGraceArea(e, contentNode!); + }; + + const handleContentLeave = (e: PointerEvent) => { + handleCreateGraceArea(e, triggerNode!); + }; + + return executeCallbacks( + on(triggerNode, "pointerleave", handleTriggerLeave), + on(contentNode, "pointerleave", handleContentLeave), + ); + }, + ); + + watch( + () => pointerGraceArea, + (pointerGraceArea) => { + const handleTrackPointerGrace = (e: PointerEvent) => { + if (!pointerGraceArea) return; + const target = e.target; + if (!isElement(target)) return; + const pointerPosition = { x: e.clientX, y: e.clientY }; + const hasEnteredTarget = + triggerNode?.contains(target) || contentNode?.contains(target); + const isPointerOutsideGraceArea = !isPointInPolygon( + pointerPosition, + pointerGraceArea, + ); + + if (hasEnteredTarget) { + handleRemoveGraceArea(); + } else if (isPointerOutsideGraceArea) { + handleRemoveGraceArea(); + pointerExit.trigger(); + } + }; + + return on(document, "pointermove", handleTrackPointerGrace); + }, + ); + + return { + isPointerInTransit, + onPointerExit: pointerExit.on, + }; +} + +type Point = { x: number; y: number }; +type Polygon = Point[]; + +function getExitSideFromRect(point: Point, rect: DOMRect): Side { + const top = Math.abs(rect.top - point.y); + const bottom = Math.abs(rect.bottom - point.y); + const right = Math.abs(rect.right - point.x); + const left = Math.abs(rect.left - point.x); + + switch (Math.min(top, bottom, right, left)) { + case left: + return "left"; + case right: + return "right"; + case top: + return "top"; + case bottom: + return "bottom"; + default: + throw new Error("unreachable"); + } +} + +function getPaddedExitPoints(exitPoint: Point, exitSide: Side, padding = 5) { + const paddedExitPoints: Point[] = []; + switch (exitSide) { + case "top": + paddedExitPoints.push( + { x: exitPoint.x - padding, y: exitPoint.y + padding }, + { x: exitPoint.x + padding, y: exitPoint.y + padding }, + ); + break; + case "bottom": + paddedExitPoints.push( + { x: exitPoint.x - padding, y: exitPoint.y - padding }, + { x: exitPoint.x + padding, y: exitPoint.y - padding }, + ); + break; + case "left": + paddedExitPoints.push( + { x: exitPoint.x + padding, y: exitPoint.y - padding }, + { x: exitPoint.x + padding, y: exitPoint.y + padding }, + ); + break; + case "right": + paddedExitPoints.push( + { x: exitPoint.x - padding, y: exitPoint.y - padding }, + { x: exitPoint.x - padding, y: exitPoint.y + padding }, + ); + break; + } + return paddedExitPoints; +} + +function getPointsFromRect(rect: DOMRect) { + const { top, right, bottom, left } = rect; + return [ + { x: left, y: top }, + { x: right, y: top }, + { x: right, y: bottom }, + { x: left, y: bottom }, + ]; +} + +// Determine if a point is inside of a polygon. +// Based on https://github.com/substack/point-in-polygon +function isPointInPolygon(point: Point, polygon: Polygon) { + const { x, y } = point; + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i]!.x; + const yi = polygon[i]!.y; + const xj = polygon[j]!.x; + const yj = polygon[j]!.y; + + // prettier-ignore + const intersect = + yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + + return inside; +} + +// Returns a new array of points representing the convex hull of the given set of points. +// https://www.nayuki.io/page/convex-hull-algorithm +function getHull

    (points: Readonly>): Array

    { + const newPoints: Array

    = points.slice(); + newPoints.sort((a: Point, b: Point) => { + if (a.x < b.x) return -1; + if (a.x > b.x) return +1; + if (a.y < b.y) return -1; + if (a.y > b.y) return +1; + return 0; + }); + return getHullPresorted(newPoints); +} + +// Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time. +function getHullPresorted

    ( + points: Readonly>, +): Array

    { + if (points.length <= 1) return points.slice(); + + const upperHull: Array

    = []; + for (let i = 0; i < points.length; i++) { + const p = points[i]!; + while (upperHull.length >= 2) { + const q = upperHull[upperHull.length - 1]!; + const r = upperHull[upperHull.length - 2]!; + if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) + upperHull.pop(); + else break; + } + upperHull.push(p); + } + upperHull.pop(); + + const lowerHull: Array

    = []; + for (let i = points.length - 1; i >= 0; i--) { + const p = points[i]!; + while (lowerHull.length >= 2) { + const q = lowerHull[lowerHull.length - 1]!; + const r = lowerHull[lowerHull.length - 2]!; + if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) + lowerHull.pop(); + else break; + } + lowerHull.push(p); + } + lowerHull.pop(); + + if ( + upperHull.length === 1 && + lowerHull.length === 1 && + upperHull[0]!.x === lowerHull[0]!.x && + upperHull[0]!.y === lowerHull[0]!.y + ) { + return upperHull; + } + return upperHull.concat(lowerHull); +} + +export { useGraceArea }; diff --git a/packages/floating-ui-svelte/src/safe-polygon.ts b/packages/floating-ui-svelte/src/safe-polygon.ts index eea767c5..496ee11c 100644 --- a/packages/floating-ui-svelte/src/safe-polygon.ts +++ b/packages/floating-ui-svelte/src/safe-polygon.ts @@ -4,6 +4,7 @@ import type { HandleCloseFn } from "./hooks/use-hover.svelte.js"; import { contains, getTarget } from "./internal/dom.js"; import { getChildren } from "./internal/get-children.js"; import { debugPolygon } from "../test/visual/components/utils/debug-polygon.svelte"; +import { sleep } from "./internal/sleep.js"; type Point = [number, number]; type Polygon = Point[]; @@ -83,36 +84,39 @@ function safePolygon(options: SafePolygonOptions = {}) { const fn: HandleCloseFn = (context) => { return (event: MouseEvent) => { function close() { + if (!context.open) return; clearTimeout(timeoutId); context.onClose(); } - + const floating = context.floating; + const reference = context.domReference; + const placement = context.placement; + const x = context.x; + const y = context.y; clearTimeout(timeoutId); if ( - !context.domReference || - !context.floating || - context.placement == null || + !reference || + !floating || + placement == null || context.x == null || context.y == null ) { return; } - const { clientX, clientY } = event; - const clientPoint: Point = [clientX, clientY]; + const clientPoint: Point = [event.clientX, event.clientY]; const target = getTarget(event) as Element | null; const isLeave = event.type === "mouseleave"; - const isOverFloatingEl = contains(context.floating, target); - const isOverReferenceEl = contains(context.domReference, target); - - const refRect = context.domReference.getBoundingClientRect(); - const rect = context.floating.getBoundingClientRect(); - const side = context.placement.split("-")[0] as Side; - const cursorLeaveFromRight = context.x > rect.right - rect.width / 2; - const cursorLeaveFromBottom = context.y > rect.bottom - rect.height / 2; + const isOverFloatingEl = contains(floating, target); + const isOverReferenceEl = contains(reference, target); + + const refRect = reference.getBoundingClientRect(); + const rect = floating.getBoundingClientRect(); + const side = placement.split("-")[0] as Side; + const cursorLeaveFromRight = x > rect.right - rect.width / 2; + const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2; const isOverReferenceRect = isInside(clientPoint, refRect); - console.log("isOverReferenceRect", isOverReferenceRect); const isFloatingWider = rect.width > refRect.width; const isFloatingTaller = rect.height > refRect.height; const left = (isFloatingWider ? refRect : rect).left; @@ -140,7 +144,7 @@ function safePolygon(options: SafePolygonOptions = {}) { if ( isLeave && isElement(event.relatedTarget) && - contains(context.floating, event.relatedTarget) + contains(floating, event.relatedTarget) ) { return; } @@ -160,10 +164,10 @@ function safePolygon(options: SafePolygonOptions = {}) { // ignored. // A constant of 1 handles floating point rounding errors. if ( - (side === "top" && context.y >= refRect.bottom - 1) || - (side === "bottom" && context.y <= refRect.top + 1) || - (side === "left" && context.x >= refRect.right - 1) || - (side === "right" && context.x <= refRect.left + 1) + (side === "top" && y >= refRect.bottom - 1) || + (side === "bottom" && y <= refRect.top + 1) || + (side === "left" && x >= refRect.right - 1) || + (side === "right" && x <= refRect.left + 1) ) { console.log("1"); return close(); @@ -368,11 +372,11 @@ function safePolygon(options: SafePolygonOptions = {}) { } } - const polygon = getPolygon([context.x, context.y]); + const polygon = getPolygon([x, y]); debugPolygon.current.tri = polygon; debugPolygon.current.rect = rectPoly; - if (isPointInPolygon([clientX, clientY], rectPoly)) { + if (isPointInPolygon([event.clientX, event.clientY], rectPoly)) { return; } @@ -385,15 +389,13 @@ function safePolygon(options: SafePolygonOptions = {}) { const cursorSpeed = getCursorSpeed(event.clientX, event.clientY); const cursorSpeedThreshold = 0.1; if (cursorSpeed !== null && cursorSpeed < cursorSpeedThreshold) { + console.log("3"); return close(); } } if ( - !isPointInPolygon( - [clientX, clientY], - getPolygon([context.x, context.y]), - ) + !isPointInPolygon([event.clientX, event.clientY], getPolygon([x, y])) ) { console.log("4"); close(); diff --git a/packages/floating-ui-svelte/test/visual/components/menu/main.svelte b/packages/floating-ui-svelte/test/visual/components/menu/main.svelte index ebcab557..689a6389 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/main.svelte @@ -6,7 +6,7 @@

    Menu

    - + console.log("Undo")} /> diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte index 7322d785..d9512368 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte @@ -75,12 +75,13 @@ const f = useFloating({ nodeId, + floatingId, open: () => open, onOpenChange: (v) => (open = v), - placement: isNested ? "right-start" : "bottom-start", + placement: () => (isNested ? "right-start" : "bottom-start"), middleware: [ offset({ - mainAxis: isNested ? 0 : 4, + mainAxis: isNested ? 40 : 4, alignmentAxis: isNested ? -4 : 0, }), flip(), @@ -98,7 +99,7 @@ const click = useClick(f.context, { event: "mousedown", toggle: () => !isNested || !allowHover, - ignoreMouse: isNested, + ignoreMouse: () => isNested, }); const role = useRole(f.context, { role: "menu" }); @@ -108,7 +109,7 @@ const listNav = useListNavigation(f.context, { listRef: () => elements, activeIndex: () => activeIndex, - nested: isNested, + nested: () => isNested, onNavigate: (v) => (activeIndex = v), }); const typeahead = useTypeahead(f.context, { @@ -130,6 +131,12 @@ typeahead, ]); + $effect(() => { + f.context.events.on("openchange", (data) => { + console.log("open change data", data); + }); + }); + // Event emitter allows you to communicate across tree components. // This effect closes all menus when an item gets clicked anywhere // in the tree. @@ -178,9 +185,9 @@ const mergedReference = box.with( () => ref, (v) => { + ref = v; f.reference = v; item.ref = v; - ref = v; } ); @@ -273,9 +280,8 @@ : "hidden", })} aria-hidden={!open} - {...ints.getFloatingProps({ - id: floatingId, - })}> + {...ints.getFloatingProps()} + id={floatingId}> {@render children?.()}
    diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte index de3df96a..729536bd 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte @@ -4,7 +4,7 @@ import { useFloatingParentNodeId } from "../../../../src/index.js"; import FloatingTree from "../../../../src/components/floating-tree/floating-tree.svelte"; import MenuImpl from "./menu-impl.svelte"; - import DebugPolygon from "../../utils/debug-polygon.svelte"; + import DebugPolygon from "../utils/debug-polygon.svelte"; let props: MenuProps & HTMLButtonAttributes & { floatingId?: string } = $props(); @@ -20,4 +20,4 @@ {/if} - + From 134c6b37d7294d8f8a419f572b1301b25851acd0 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Thu, 23 Jan 2025 20:25:54 -0500 Subject: [PATCH 60/87] some progress I think? --- .../src/hooks/use-hover.svelte.ts | 488 +++++++++--------- .../src/hooks/use-list-navigation.svelte.ts | 1 + .../floating-ui-svelte/src/safe-polygon.ts | 19 +- .../visual/components/menu/menu-impl.svelte | 7 +- 4 files changed, 268 insertions(+), 247 deletions(-) diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index 3290b4ba..f8cbbe99 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -21,12 +21,10 @@ import { useFloatingParentNodeId, useFloatingTree, } from "../components/floating-tree/hooks.svelte.js"; -import { on } from "svelte/events"; import { snapshotFloatingContext } from "../internal/snapshot.svelte.js"; import { watch } from "../internal/watch.svelte.js"; import { extract } from "../internal/extract.js"; import type { ElementProps } from "./use-interactions.svelte.js"; -import { sleep } from "../internal/sleep.js"; interface DelayOptions { /** @@ -111,31 +109,67 @@ function getDelay( return value?.[prop]; } -function useHover(context: FloatingContext, options: UseHoverOptions = {}) { - const enabled = $derived(extract(options.enabled, true)); - const mouseOnly = $derived(extract(options.mouseOnly, false)); - const delay = $derived(extract(options.delay, 0)); - const restMs = $derived(extract(options.restMs, 0)); - const move = $derived(extract(options.move, true)); - const handleClose: HandleCloseFn | null = options.handleClose ?? null; +function useHover(context: FloatingContext, opts: UseHoverOptions = {}) { + const enabled = $derived(extract(opts.enabled, true)); + const delay = $derived(extract(opts.delay, 0)); + const handleClose = opts.handleClose ?? null; + const mouseOnly = $derived(extract(opts.mouseOnly, false)); + const restMs = $derived(extract(opts.restMs, 0)); + const move = $derived(extract(opts.move, true)); + const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); - let pointerType: PointerType | undefined = undefined; + let pointerType: PointerType | undefined; let timeout = -1; - let handler: ((event: MouseEvent) => void) | undefined = undefined; + let handler: ((e: MouseEvent) => void) | undefined = noop; let restTimeout = -1; let blockMouseMove = true; let performedPointerEventsMutation = false; let unbindMouseMove = noop; let restTimeoutPending = false; - function isHoverOpen(): boolean { + function isHoverOpen() { const type = context.data.openEvent?.type; - if (!type) return false; - return type.includes("mouse") && type !== "mousedown"; + return type?.includes("mouse") && type !== "mousedown"; } + watch([() => enabled, () => context.events], () => { + if (!enabled) return; + + function onOpenChange({ open }: { open: boolean }) { + if (open) return; + window.clearTimeout(timeout); + window.clearTimeout(restTimeout); + blockMouseMove = true; + restTimeoutPending = false; + } + + return context.events.on("openchange", onOpenChange); + }); + + watch( + [() => enabled, () => context.open, () => context.floating], + ([isEnabled, isOpen, floatingEl]) => { + if (!isEnabled) return; + if (!handleClose) return; + if (!isOpen) return; + + function onLeave(event: MouseEvent) { + if (isHoverOpen()) { + context.onOpenChange(false, event, "hover"); + } + } + + const html = getDocument(floatingEl).documentElement; + + html.addEventListener("mouseleave", onLeave); + return () => { + html.removeEventListener("mouseleave", onLeave); + }; + }, + ); + function closeWithDelay( event: Event, runElseBranch = true, @@ -149,7 +183,7 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { closeDelay, ); } else if (runElseBranch) { - window.clearTimeout(timeout); + clearTimeout(timeout); context.onOpenChange(false, event, reason); } } @@ -160,194 +194,70 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { } function clearPointerEvents() { - if (!performedPointerEventsMutation) return; - - const body = getDocument(context.floating).body; - body.style.pointerEvents = ""; - body.removeAttribute(safePolygonIdentifier); - performedPointerEventsMutation = false; - } - - function setPointerType(event: PointerEvent) { - if (!isPointerType(event.pointerType)) return; - pointerType = event.pointerType; - } - - function onReferenceMouseMove(event: MouseEvent) { - function handleMouseMove() { - if (!blockMouseMove && !context.open) { - context.onOpenChange(true, event, "hover"); - } - } - - if (mouseOnly && !isMouseLikePointerType(pointerType)) return; - if (context.open || restMs === 0) return; - - // ignore insignificant movements to account for tremors - if (restTimeoutPending && event.movementX ** 2 + event.movementY ** 2 < 2) { - return; - } - - window.clearTimeout(restTimeout); - - if (pointerType === "touch") { - handleMouseMove(); - } else { - restTimeoutPending = true; - restTimeout = window.setTimeout(handleMouseMove, restMs); + if (performedPointerEventsMutation) { + const body = getDocument(context.floating).body; + body.style.pointerEvents = ""; + body.removeAttribute(safePolygonIdentifier); + performedPointerEventsMutation = false; } } - function onFloatingMouseEnter() { - window.clearTimeout(timeout); - } - - function isClickLikeOpenEvent(): boolean { + function isClickLikeOpenEvent() { return context.data.openEvent ? ["click", "mousedown"].includes(context.data.openEvent.type) : false; } - function onFloatingMouseLeave(event: MouseEvent) { - if (isClickLikeOpenEvent()) return; - closeWithDelay(event, false); - } - - // When closing before opening, clear the delay timeouts to cancel it - // from showing. - watch([() => enabled, () => context.events], () => { - if (!enabled) return; - - const onOpenChange = ({ open }: { open: boolean }) => { - if (!open) { - window.clearTimeout(timeout); - window.clearTimeout(restTimeout); - blockMouseMove = true; - restTimeoutPending = false; - } - }; - - return context.events.on("openchange", onOpenChange); - }); - - watch([() => enabled, () => context.open, () => context.floating], () => { - if (!enabled) return; - if (!handleClose) return; - if (!context.open) return; - if (!context.floating) return; - - function onLeave(event: MouseEvent) { - if (!isHoverOpen()) return; - - context.onOpenChange(false, event, "hover"); - } - - const html = getDocument(context.floating).documentElement; - return on(html, "mouseleave", onLeave); - }); - - // Registering the mouse events on the reference directly to bypass Svelte's - // delegation system. If the cursor was on a disabled element and then entered - // the reference (no gap), `mouseenter` doesn't fire in the delegation system. - watch( - [ - () => context.domReference, - () => context.floating, - () => context.reference, - () => enabled, - () => mouseOnly, - () => restMs, - () => move, - () => context.open, - () => tree?.nodes, - () => delay, - () => context.data.floatingContext, - () => isClickLikeOpenEvent(), - ], - () => { - if (!enabled) return; - const localMove = move; - - if (isElement(context.domReference)) { - const ref = context.domReference as unknown as HTMLElement; - localMove && - ref.addEventListener("mousemove", onMouseEnter, { once: true }); - return () => { - localMove && ref.removeEventListener("mousemove", onMouseEnter); - }; - } - }, - ); + // watch( + // [ + // () => enabled, + // () => context.floating, + // () => context.reference, + // () => context.domReference, + // () => mouseOnly, + // () => restMs, + // () => move, + // () => context.open, + // () => tree?.nodes, + // () => delay, + // () => context.data.floatingContext, + // ], + // ([ + // isEnabled, + // floating, + // reference, + // domReference, + // mouseOnly, + // restMs, + // move, + // isOpen, + // treeNodes, + // delay, + // floatingContext, + // ]) => {}, + // ); - // Block pointer-events of every element other than the reference and floating - // while the floating element is open and has a `handleClose` handler. Also - // handles nested floating elements. - // https://github.com/floating-ui/floating-ui/issues/1722 - watch.pre( - [ - () => enabled, - () => context.open, - () => context.floating, - () => context.domReference, - () => handleClose, - () => isHoverOpen(), - () => tree?.nodes, - ], - () => { - if (!enabled) return; - if ( - context.open && - handleClose?.__options.blockPointerEvents && - isHoverOpen() - ) { - performedPointerEventsMutation = true; - const floatingEl = context.floating; - if (!isElement(context.domReference) || !floatingEl) return; - - const body = getDocument(context.floating).body; - body.setAttribute(safePolygonIdentifier, ""); - - const ref = context.domReference as unknown as - | HTMLElement - | SVGSVGElement; - - const parentFloating = tree?.nodes.find((node) => node.id === parentId) - ?.context?.floating; + // Ensure the floating element closes after scrolling even if the pointer + // did not move. + // https://github.com/floating-ui/floating-ui/discussions/1692 + function onScrollMouseLeave(event: MouseEvent) { + if (isClickLikeOpenEvent()) return; + if (!context.data.floatingContext) return; - if (parentFloating) { - parentFloating.style.pointerEvents = ""; + handleClose?.({ + ...snapshotFloatingContext(context.data.floatingContext).current, + tree, + x: event.clientX, + y: event.clientY, + onClose() { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event); } - - body.style.pointerEvents = "none"; - ref.style.pointerEvents = "auto"; - floatingEl.style.pointerEvents = "auto"; - - return () => { - body.style.pointerEvents = ""; - ref.style.pointerEvents = ""; - floatingEl.style.pointerEvents = ""; - }; - } - }, - ); - - $effect.pre(() => { - if (!context.open) { - pointerType = undefined; - restTimeoutPending = false; - cleanupMouseMoveHandler(); - clearPointerEvents(); - } - }); - - watch([() => enabled, () => context.domReference], () => { - return () => { - cleanupMouseMoveHandler(); - window.clearTimeout(timeout); - window.clearTimeout(restTimeout); - clearPointerEvents(); - }; - }); + }, + })(event); + } function onMouseEnter(event: MouseEvent) { window.clearTimeout(timeout); @@ -361,11 +271,10 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { } const openDelay = getDelay(delay, "open", pointerType); - const isOpen = context.open; if (openDelay) { timeout = window.setTimeout(() => { - if (!isOpen) { + if (!context.open) { context.onOpenChange(true, event, "hover"); } }, openDelay); @@ -376,22 +285,23 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { function onMouseLeave(event: MouseEvent) { if (isClickLikeOpenEvent()) return; - const isOpen = context.open; unbindMouseMove(); const doc = getDocument(context.floating); + window.clearTimeout(restTimeout); restTimeoutPending = false; if (handleClose && context.data.floatingContext) { // Prevent clearing `onScrollMouseLeave` timeout. - if (!isOpen) { + if (!context.open) { window.clearTimeout(timeout); } + handler = handleClose({ ...snapshotFloatingContext(context.data.floatingContext).current, - tree: tree, + tree, x: event.clientX, y: event.clientY, onClose: () => { @@ -401,18 +311,14 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { closeWithDelay(event, true, "safe-polygon"); } }, - domReference: context.domReference, - floating: context.floating, }); - const localHandler = (e: MouseEvent) => { - if (!context.floating) return; - handler?.(e); - }; + const localHandler = handler; - const remove = on(doc, "mousemove", localHandler); - unbindMouseMove(); - unbindMouseMove = remove; + doc.addEventListener("mousemove", localHandler); + unbindMouseMove = () => { + doc.removeEventListener("mousemove", localHandler); + }; return; } @@ -429,50 +335,154 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}) { } } - // Ensure the floating element closes after scrolling even if the pointer - // did not move. - // https://github.com/floating-ui/floating-ui/discussions/1692 - function onScrollMouseLeave(event: MouseEvent) { - if (isClickLikeOpenEvent()) return; - if (!context.data.floatingContext) return; + $effect(() => { + if (!enabled) return; - handleClose?.({ - ...snapshotFloatingContext(context.data.floatingContext).current, - tree, - x: event.clientX, - y: event.clientY, - onClose: () => { - clearPointerEvents(); - cleanupMouseMoveHandler(); - if (!isClickLikeOpenEvent()) { - closeWithDelay(event); + if (isElement(context.domReference)) { + const ref = context.domReference as unknown as HTMLElement; + + if (move) { + ref.addEventListener("mousemove", onMouseEnter, { once: true }); + return () => { + ref.removeEventListener("mousemove", onMouseEnter); + }; + } + } + }); + + // Block pointer-events of every element other than the reference and floating + // while the floating element is open and has a `handleClose` handler. Also + // handles nested floating elements. + // https://github.com/floating-ui/floating-ui/issues/1722 + watch.pre( + [ + () => enabled, + () => context.open, + () => context.floating, + () => context.domReference, + () => tree?.nodes, + () => context.reference, + ], + ([isEnabled, isOpen, floating, domReference, treeNodes]) => { + if (!isEnabled) return; + + if ( + isOpen && + handleClose?.__options.blockPointerEvents && + isHoverOpen() + ) { + performedPointerEventsMutation = true; + + if (isElement(domReference) && floating) { + const body = getDocument(floating).body; + body.setAttribute(safePolygonIdentifier, ""); + + const ref = domReference as unknown as HTMLElement | SVGSVGElement; + + const parentFloating = treeNodes?.find((node) => node.id === parentId) + ?.context?.floating; + + if (parentFloating) { + parentFloating.style.pointerEvents = ""; + } + + body.style.pointerEvents = "none"; + ref.style.pointerEvents = "auto"; + floating.style.pointerEvents = "auto"; + + return () => { + body.style.pointerEvents = ""; + ref.style.pointerEvents = ""; + floating.style.pointerEvents = ""; + }; } - }, - })(event); + } + }, + ); + + watch.pre( + () => context.open, + (isOpen) => { + if (isOpen) return; + pointerType = undefined; + restTimeoutPending = false; + cleanupMouseMoveHandler(); + clearPointerEvents(); + }, + ); + + watch([() => enabled, () => context.domReference], () => { + return () => { + cleanupMouseMoveHandler(); + window.clearTimeout(timeout); + window.clearTimeout(restTimeout); + clearPointerEvents(); + }; + }); + + function setPointerType(event: PointerEvent) { + pointerType = isPointerType(event.pointerType) + ? event.pointerType + : undefined; } - const reference: ElementProps["reference"] = $derived({ - onpointerdown: setPointerType, - onpointerenter: setPointerType, - onmousemove: onReferenceMouseMove, - onmouseenter: onMouseEnter, - onmouseleave: (event) => { - if (context.open) { - onScrollMouseLeave(event); + const reference: ElementProps["reference"] = { + onpointerdown: (event) => setPointerType(event), + onpointerenter: (event) => setPointerType(event), + onmouseenter: (event) => { + onMouseEnter(event); + }, + onmousemove: (event) => { + const isOpen = context.open; + function handleMouseMove() { + if (!blockMouseMove && !isOpen) { + context.onOpenChange(true, event, "hover"); + } + } + + if (mouseOnly && !isMouseLikePointerType(pointerType)) { + return; } + + if (isOpen || restMs === 0) return; + + // ignore insignificant mouse movements to account for tremors + if ( + restTimeoutPending && + event.movementX ** 2 + event.movementY ** 2 < 2 + ) { + return; + } + + window.clearTimeout(restTimeout); + + if (pointerType === "touch") { + handleMouseMove(); + } else { + restTimeoutPending = true; + restTimeout = window.setTimeout(handleMouseMove, restMs); + } + }, + onmouseleave: (event) => { onMouseLeave(event); + if (!context.open) return; + onScrollMouseLeave(event); }, - }); + }; - const floating: ElementProps["floating"] = $derived({ - onmouseenter: onFloatingMouseEnter, + const floating: ElementProps["floating"] = { + onmouseenter: () => { + console.log("mouseenter floating"); + window.clearTimeout(timeout); + }, onmouseleave: (event) => { - onFloatingMouseLeave(event); - if (isElement(context.domReference)) { - onScrollMouseLeave(event); + console.log("mouseleave floating"); + onScrollMouseLeave(event); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event, false); } }, - }); + }; return { get reference() { diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index 1eb12f36..5d65269d 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -486,6 +486,7 @@ function useListNavigation( const itemOnMouseMove: MouseEventHandler = ({ currentTarget, }) => { + console.log("item on mouse move"); forceSyncFocus = true; forceScrollIntoView = false; syncCurrentTarget(currentTarget); diff --git a/packages/floating-ui-svelte/src/safe-polygon.ts b/packages/floating-ui-svelte/src/safe-polygon.ts index 496ee11c..1b0e4fec 100644 --- a/packages/floating-ui-svelte/src/safe-polygon.ts +++ b/packages/floating-ui-svelte/src/safe-polygon.ts @@ -83,8 +83,9 @@ function safePolygon(options: SafePolygonOptions = {}) { const fn: HandleCloseFn = (context) => { return (event: MouseEvent) => { + const isOpen = context.open; function close() { - if (!context.open) return; + if (!isOpen) return; clearTimeout(timeoutId); context.onClose(); } @@ -93,14 +94,15 @@ function safePolygon(options: SafePolygonOptions = {}) { const placement = context.placement; const x = context.x; const y = context.y; + const tree = context.tree; clearTimeout(timeoutId); if ( !reference || !floating || placement == null || - context.x == null || - context.y == null + x == null || + y == null ) { return; } @@ -108,6 +110,7 @@ function safePolygon(options: SafePolygonOptions = {}) { const clientPoint: Point = [event.clientX, event.clientY]; const target = getTarget(event) as Element | null; const isLeave = event.type === "mouseleave"; + console.log("event.type", event.type); const isOverFloatingEl = contains(floating, target); const isOverReferenceEl = contains(reference, target); @@ -117,6 +120,7 @@ function safePolygon(options: SafePolygonOptions = {}) { const cursorLeaveFromRight = x > rect.right - rect.width / 2; const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2; const isOverReferenceRect = isInside(clientPoint, refRect); + const isOverFloatingRect = isInside(clientPoint, rect); const isFloatingWider = rect.width > refRect.width; const isFloatingTaller = rect.height > refRect.height; const left = (isFloatingWider ? refRect : rect).left; @@ -124,7 +128,8 @@ function safePolygon(options: SafePolygonOptions = {}) { const top = (isFloatingTaller ? refRect : rect).top; const bottom = (isFloatingTaller ? refRect : rect).bottom; - if (isOverFloatingEl) { + if (isOverFloatingEl || isOverFloatingRect) { + console.log("isOverfloatingel", isOverFloatingEl); hasLanded = true; if (!isLeave) return; @@ -151,9 +156,9 @@ function safePolygon(options: SafePolygonOptions = {}) { // If any nested child is open, abort. if ( - context.tree && - getChildren(context.tree.nodes, context.nodeId).some( - ({ context }) => context?.open, + tree && + getChildren(tree.nodes, context.nodeId).some( + ({ context: ctx }) => ctx?.open, ) ) { return; diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte index d9512368..4a94b311 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte @@ -93,7 +93,11 @@ const hover = useHover(f.context, { enabled: () => isNested && allowHover, delay: { open: 75 }, - handleClose: safePolygon({ blockPointerEvents: true }), + handleClose: safePolygon({ + blockPointerEvents: true, + buffer: 1, + requireIntent: false, + }), }); const click = useClick(f.context, { @@ -243,6 +247,7 @@ return activeIndex; }, set activeIndex(v: number | null) { + console.log("setting active index"); activeIndex = v; }, getItemProps: (u) => ints.getItemProps(u), From 62ae2d7a1caf3c27287da23e7a9c48e9547e2af0 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Thu, 23 Jan 2025 21:22:49 -0500 Subject: [PATCH 61/87] ughhhhhhhhhhh --- packages/floating-ui-svelte/package.json | 6 +-- .../floating-portal/floating-portal.svelte | 6 +-- .../floating-portal/hooks.svelte.ts | 5 ++- .../src/hooks/use-floating.svelte.ts | 2 +- .../src/hooks/use-hover.svelte.ts | 13 +++++++ .../src/hooks/use-interactions.svelte.ts | 5 +-- .../floating-ui-svelte/src/safe-polygon.ts | 1 + .../floating-portal/components/main.svelte | 25 +++++++++++++ .../floating-portal/floating-portal.test.ts | 37 +++++++++++++++++++ .../visual/components/menu/menu-item.svelte | 2 + pnpm-lock.yaml | 2 +- 11 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 packages/floating-ui-svelte/test/unit/components/floating-portal/components/main.svelte create mode 100644 packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts diff --git a/packages/floating-ui-svelte/package.json b/packages/floating-ui-svelte/package.json index bc9d4186..f4ba5206 100644 --- a/packages/floating-ui-svelte/package.json +++ b/packages/floating-ui-svelte/package.json @@ -11,9 +11,7 @@ "test:watch": "pnpm test --watch", "sync": "svelte-kit sync && pnpm build" }, - "files": [ - "dist" - ], + "files": ["dist"], "sideEffects": false, "svelte": "./dist/index.js", "types": "./dist/index.d.ts", @@ -40,7 +38,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/svelte": "^5.2.6", "@testing-library/user-event": "^14.5.2", - "bits-ui": "1.0.0-next.78", + "bits-ui": "^1.0.0-next.78", "clsx": "^2.1.1", "csstype": "^3.1.3", "lucide-svelte": "^0.469.0", diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index d87e124e..237882c6 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -47,7 +47,7 @@ let { children, id, - root = typeof document === "undefined" ? null : document.body, + root, preserveTabOrder = true, }: FloatingPortalProps = $props(); @@ -76,7 +76,7 @@ !!(root || portalNode.current) ); - watch( + watch.pre( [() => portalNode.current, () => preserveTabOrder, () => modal], () => { if (!portalNode.current || !preserveTabOrder || modal) return; @@ -102,7 +102,7 @@ } ); - watch([() => portalNode.current, () => open], () => { + watch.pre([() => portalNode.current, () => open], () => { if (!portalNode.current || open) return; enableFocusInside(portalNode.current); }); diff --git a/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts b/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts index fabd685c..ac87aff9 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts +++ b/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts @@ -31,7 +31,7 @@ function usePortalContext() { interface UseFloatingPortalNodeProps { id?: MaybeGetter; - root?: MaybeGetter; + root?: MaybeGetter; } function useFloatingPortalNode(props: UseFloatingPortalNodeProps = {}) { @@ -46,6 +46,9 @@ function useFloatingPortalNode(props: UseFloatingPortalNodeProps = {}) { $effect.pre(() => { return () => { portalNode?.remove(); + // Allow the subsequent layout effects to create a new node on updates. + // The portal node will still be cleaned up on unmount. + // https://github.com/floating-ui/floating-ui/issues/2454 queueMicrotask(() => { portalNode = null; }); diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index ed76134d..8ea9bddf 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -25,7 +25,7 @@ import { import { extract } from "../internal/extract.js"; import { noop } from "../internal/noop.js"; import { isElement } from "@floating-ui/utils/dom"; -import { useId } from "bits-ui"; +import { useId } from "./use-id.js"; interface UseFloatingOptions { /** diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index f8cbbe99..5befa7e7 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -385,11 +385,24 @@ function useHover(context: FloatingContext, opts: UseHoverOptions = {}) { if (parentFloating) { parentFloating.style.pointerEvents = ""; } + console.log("Before applying styles:"); + console.log("Floating element style:", floating.style.cssText); + console.log( + "Floating element computed style:", + window.getComputedStyle(floating).pointerEvents, + ); body.style.pointerEvents = "none"; ref.style.pointerEvents = "auto"; floating.style.pointerEvents = "auto"; + console.log("After applying styles:"); + console.log("Floating element style:", floating.style.cssText); + console.log( + "Floating element computed style:", + window.getComputedStyle(floating).pointerEvents, + ); + return () => { body.style.pointerEvents = ""; ref.style.pointerEvents = ""; diff --git a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts index 4470c6d9..e280646c 100644 --- a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts @@ -60,9 +60,8 @@ function mergeProps( }) .concat(userProps) .reduce((acc: Record, props) => { - if (!props) { - return acc; - } + if (!props) return acc; + for (const [key, value] of Object.entries(props)) { if (isItem && [ACTIVE_KEY, SELECTED_KEY].includes(key)) { continue; diff --git a/packages/floating-ui-svelte/src/safe-polygon.ts b/packages/floating-ui-svelte/src/safe-polygon.ts index 1b0e4fec..fc7d59e9 100644 --- a/packages/floating-ui-svelte/src/safe-polygon.ts +++ b/packages/floating-ui-svelte/src/safe-polygon.ts @@ -111,6 +111,7 @@ function safePolygon(options: SafePolygonOptions = {}) { const target = getTarget(event) as Element | null; const isLeave = event.type === "mouseleave"; console.log("event.type", event.type); + console.log("event.target", event.target); const isOverFloatingEl = contains(floating, target); const isOverReferenceEl = contains(reference, target); diff --git a/packages/floating-ui-svelte/test/unit/components/floating-portal/components/main.svelte b/packages/floating-ui-svelte/test/unit/components/floating-portal/components/main.svelte new file mode 100644 index 00000000..bdfed475 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-portal/components/main.svelte @@ -0,0 +1,25 @@ + + + + + {#if open} +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts b/packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts new file mode 100644 index 00000000..7d1d3244 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts @@ -0,0 +1,37 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { expect, it } from "vitest"; +import Main from "./components/main.svelte"; +import { sleep } from "../../../utils.js"; + +it("creates a custom id node", async () => { + render(Main, { id: "custom-id" }); + + await waitFor(() => + expect(document.querySelector("#custom-id")).toBeInTheDocument(), + ); + const customId = document.getElementById("custom-id"); + customId?.remove(); +}); + +it("uses a custom id node as the root", async () => { + const customRoot = document.createElement("div"); + customRoot.id = "custom-root"; + document.body.appendChild(customRoot); + render(Main, { id: "custom-root" }); + await fireEvent.click(screen.getByTestId("reference")); + await sleep(200); + await act(async () => {}); + await waitFor(() => { + expect(screen.getByTestId("floating").parentElement?.parentElement).toBe( + customRoot, + ); + }); + customRoot.remove(); +}); diff --git a/packages/floating-ui-svelte/test/visual/components/menu/menu-item.svelte b/packages/floating-ui-svelte/test/visual/components/menu/menu-item.svelte index 0b044f9a..f5e40da9 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu/menu-item.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-item.svelte @@ -51,8 +51,10 @@ menu.setHasFocusInside(true); }, onmouseenter: (event: ButtonEvent) => { + console.log("menuitem enter"); rest.onmouseenter?.(event); if (menu.allowHover && menu.open) { + console.log("setting menu active index to", item.index); menu.activeIndex = item.index; } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c631719f..2b449628 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,7 +55,7 @@ importers: specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) bits-ui: - specifier: 1.0.0-next.78 + specifier: ^1.0.0-next.78 version: 1.0.0-next.78(svelte@5.17.3) clsx: specifier: ^2.1.1 From ce7cba11aa8a9ce435059817ece85311d1330015 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 24 Jan 2025 12:37:39 -0500 Subject: [PATCH 62/87] lfg --- .../src/components/floating-overlay.svelte | 25 ++++--- .../src/hooks/use-floating.svelte.ts | 35 ++++++---- .../src/hooks/use-hover.svelte.ts | 68 ++++--------------- .../src/hooks/use-list-navigation.svelte.ts | 1 - .../src/hooks/use-position.svelte.ts | 22 +++++- .../src/hooks/use-transition.svelte.ts | 2 +- .../src/internal/position-context.ts | 9 +++ .../src/internal/style-object-to-string.ts | 23 ++++++- .../floating-ui-svelte/src/safe-polygon.ts | 7 +- packages/floating-ui-svelte/src/types.ts | 1 + .../test/visual/components/arrow/demo.svelte | 22 +++--- .../components/complex-grid/main.svelte | 5 +- .../test/visual/components/grid/main.svelte | 8 +-- .../visual/components/menu/menu-impl.svelte | 8 +-- 14 files changed, 120 insertions(+), 116 deletions(-) create mode 100644 packages/floating-ui-svelte/src/internal/position-context.ts diff --git a/packages/floating-ui-svelte/src/components/floating-overlay.svelte b/packages/floating-ui-svelte/src/components/floating-overlay.svelte index 1863baca..411b0feb 100644 --- a/packages/floating-ui-svelte/src/components/floating-overlay.svelte +++ b/packages/floating-ui-svelte/src/components/floating-overlay.svelte @@ -78,10 +78,7 @@ + + + + diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte new file mode 100644 index 00000000..662d2ba3 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte @@ -0,0 +1,47 @@ + + +{#snippet Dialog()} +
    + {@render children?.()} +
    +{/snippet} + + +{#if open} + {#if modal == null} + {@render Dialog()} + {:else} + + {@render Dialog()} + + {/if} +{/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-portaled-children.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-portaled-children.svelte new file mode 100644 index 00000000..a1ce454b --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-portaled-children.svelte @@ -0,0 +1,26 @@ + + + +{#if open} +
    + + + +
    +{/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss.svelte new file mode 100644 index 00000000..ceb44ae4 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss.svelte @@ -0,0 +1,41 @@ + + + + +{#if open} +
    + +
    +{/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/third-party.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/third-party.svelte new file mode 100644 index 00000000..427d2eb3 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/third-party.svelte @@ -0,0 +1,26 @@ + + + +{#if open} + +
    +
    +
    +{/if} From c0a394b69991705a4f6a195575b9807cee885b4b Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 26 Jan 2025 21:45:20 -0500 Subject: [PATCH 70/87] need to fix the nested dismiss by snapshotting state or something? --- .../floating-portal/floating-portal.svelte | 36 +++++- .../floating-portal/hooks.svelte.ts | 12 ++ .../src/hooks/use-dismiss.svelte.ts | 65 ++++++---- .../src/hooks/use-floating.svelte.ts | 10 +- .../src/internal/attributes.ts | 6 + .../src/internal/get-children.ts | 30 ++++- .../test/unit/hooks/use-dismiss.test.ts | 95 +++++++++++++++ .../use-dismiss/dismiss-dialog.svelte | 48 ++++++++ .../use-dismiss/dismiss-nested-dialog.svelte | 22 ++++ .../use-dismiss/dismiss-nested-nested.svelte | 19 +++ .../test/visual/components/new/main.svelte | 104 +++------------- .../components/popover/popover-impl.svelte | 106 +++++++++++++++++ .../visual/components/popover/popover.svelte | 111 +++--------------- 13 files changed, 450 insertions(+), 214 deletions(-) create mode 100644 packages/floating-ui-svelte/src/internal/attributes.ts create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-dialog.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/popover/popover-impl.svelte diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index 237882c6..6646e29d 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -43,6 +43,7 @@ import Portal from "./portal.svelte"; import { sleep } from "../../internal/sleep.js"; import { handleGuardFocus } from "../../internal/handle-guard-focus.js"; + import { FLOATING_ID_ATTRIBUTE } from "../../internal/attributes.js"; let { children, @@ -51,16 +52,36 @@ preserveTabOrder = true, }: FloatingPortalProps = $props(); - const portalNode = useFloatingPortalNode({ - id: () => id, - root: () => root, - }); - let focusManagerState = $state.raw(null); let beforeOutsideGuard = $state(null); let afterOutsideGuard = $state(null); let beforeInsideGuard = $state(null); let afterInsideGuard = $state(null); + let portalOriginMarker = $state(null); + let originFloatingId = $state(null); + + const portalNode = useFloatingPortalNode({ + id: () => id, + root: () => root, + originFloatingId: () => originFloatingId, + }); + + $effect(() => { + if (!portalOriginMarker) { + originFloatingId = null; + return; + } + const closestFloatingNode = portalOriginMarker.closest( + `[${FLOATING_ID_ATTRIBUTE}]` + ); + if (closestFloatingNode) { + originFloatingId = closestFloatingNode.getAttribute( + FLOATING_ID_ATTRIBUTE + ); + } else { + originFloatingId = null; + } + }); const modal = $derived(focusManagerState?.modal); const open = $derived(focusManagerState?.open); @@ -138,6 +159,11 @@ }); + + {#if shouldRenderGuards && portalNode.current} ; root?: MaybeGetter; + originFloatingId?: MaybeGetter; } function useFloatingPortalNode(props: UseFloatingPortalNodeProps = {}) { const id = $derived(extract(props.id)); const root = $derived(extract(props.root)); + const originFloatingId = $derived(extract(props.originFloatingId)); const uniqueId = useId(); const portalContext = usePortalContext(); let portalNode = $state(null); + watch( + () => originFloatingId, + () => { + if (originFloatingId && portalNode) { + portalNode.setAttribute(ORIGIN_ID_ATTRIBUTE, originFloatingId); + } + }, + ); + $effect.pre(() => { return () => { portalNode?.remove(); diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index 469bc5fd..6333bc7e 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -15,7 +15,7 @@ import { isRootElement, } from "../internal/dom.js"; import type { FloatingContext } from "./use-floating.svelte.js"; -import type { FloatingTreeType, MaybeGetter } from "../types.js"; +import type { MaybeGetter } from "../types.js"; import { useFloatingTree } from "../components/floating-tree/hooks.svelte.js"; import { getChildren } from "../internal/get-children.js"; import { on } from "svelte/events"; @@ -23,6 +23,7 @@ import { extract } from "../internal/extract.js"; import { watch } from "../internal/watch.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; import { untrack } from "svelte"; +import { FLOATING_ID_ATTRIBUTE } from "../internal/attributes.js"; const bubbleHandlerKeys = { pointerdown: "onpointerdown", @@ -164,18 +165,22 @@ function useDismiss( if (isComposing) return; const nodeId = context.data.floatingContext?.nodeId; - const children = tree ? getChildren(tree.nodes, nodeId) : []; + const children = tree ? getChildren(tree?.nodes, nodeId) : []; if (!bubbleOptions.escapeKey) { event.stopPropagation(); if (children.length > 0) { - const hasOpenChild = children.some( - (child) => - child.context?.open && !child.context.data.__escapeKeyBubbles, - ); + let shouldDismiss = true; + + for (const child of children) { + if (child.context?.open && !child.context.data.__escapeKeyBubbles) { + shouldDismiss = false; + break; + } + } - if (hasOpenChild) return; + if (!shouldDismiss) return; } } @@ -285,11 +290,9 @@ function useDismiss( const nodeId = context.data.floatingContext?.nodeId; - const children = tree ? getChildren(tree.nodes, nodeId) : []; - const targetIsInsideChildren = - children.length && - children.some((node) => + tree && + getChildren(tree?.nodes, nodeId).some((node) => isEventTargetWithin(event, node.context?.floating), ); @@ -301,15 +304,34 @@ function useDismiss( return; } + console.log("treeNodes", tree?.nodes); + const children = tree ? getChildren(tree?.nodes, nodeId) : []; + if (children.length > 0) { - const hasOpenChild = children.some( - (child) => - child.context?.open && !child.context.data.__outsidePressBubbles, - ); + let shouldDismiss = true; - if (hasOpenChild) return; + for (const child of children) { + if (child.context?.open && !child.context.data.__outsidePressBubbles) { + shouldDismiss = false; + break; + } + } + + if (!shouldDismiss) return; } + // if the event occurred inside a portal created within this floating element, return + const closestPortalOrigin = isElement(target) + ? target.closest("[data-floating-ui-origin-id]") + : null; + + if ( + closestPortalOrigin && + closestPortalOrigin.getAttribute("data-floating-ui-origin-id") === + context.floatingId + ) + return; + context.onOpenChange(false, event, "outside-press"); } @@ -321,12 +343,10 @@ function useDismiss( getTarget(event)?.addEventListener(outsidePressEvent, callback); } - $effect(() => { + $effect.pre(() => { if (!context.open || !enabled) return; - untrack(() => { - context.data.__escapeKeyBubbles = bubbleOptions.escapeKey; - context.data.__outsidePressBubbles = bubbleOptions.outsidePress; - }); + context.data.__escapeKeyBubbles = bubbleOptions.escapeKey; + context.data.__outsidePressBubbles = bubbleOptions.outsidePress; let compositionTimeout = -1; @@ -455,6 +475,7 @@ function useDismiss( [captureHandlerKeys[outsidePressEvent]]: () => { insideTree = true; }, + [FLOATING_ID_ATTRIBUTE]: context.floatingId, }); return { @@ -470,4 +491,4 @@ function useDismiss( } export type { UseDismissOptions }; -export { useDismiss }; +export { useDismiss, normalizeProp }; diff --git a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts index 2af46a9f..d6d7f0b3 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -149,6 +149,7 @@ class FloatingOptions { this.onFloatingChange = options.onFloatingChange ?? noop; this.whileElementsMounted = options.whileElementsMounted; this.nodeId = box.with(() => extract(options.nodeId)); + this.rootContext = box.with( () => extract(options.rootContext) as FloatingRootContext | undefined, ); @@ -296,6 +297,12 @@ class FloatingContext // Used to handle pointer event style synchronization between tree nodes. // Marked as internal and deprecated, deprecated will push it to the bottom of intellisense. __position: PositionState; + + /** + * @internal - do not use + * @deprecated + */ + // Used to handle checking if events occurred within children portals of the floating element. } class FloatingState { @@ -337,12 +344,13 @@ class FloatingState { positionState: this.#position, }); - $effect(() => { + $effect.pre(() => { this.#rootContext.data.floatingContext = this.context; const node = this.#tree?.nodes.find( (node) => node.id === this.options.nodeId.current, ); + if (node) { node.context = this.context; } diff --git a/packages/floating-ui-svelte/src/internal/attributes.ts b/packages/floating-ui-svelte/src/internal/attributes.ts new file mode 100644 index 00000000..ad415d39 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/attributes.ts @@ -0,0 +1,6 @@ +import { createAttribute } from "./dom.js"; + +const FLOATING_ID_ATTRIBUTE = createAttribute("id"); +const ORIGIN_ID_ATTRIBUTE = createAttribute("origin-id"); + +export { FLOATING_ID_ATTRIBUTE, ORIGIN_ID_ATTRIBUTE }; diff --git a/packages/floating-ui-svelte/src/internal/get-children.ts b/packages/floating-ui-svelte/src/internal/get-children.ts index caecf814..acdacf5e 100644 --- a/packages/floating-ui-svelte/src/internal/get-children.ts +++ b/packages/floating-ui-svelte/src/internal/get-children.ts @@ -5,7 +5,7 @@ function getChildren( id: string | undefined, ) { let allChildren = nodes.filter( - (node) => node.parentId === id && node.context?.open, + (node) => node.parentId === id && node.context?.data?.open, ); let currentChildren = allChildren; @@ -22,6 +22,32 @@ function getChildren( return allChildren; } +function getRawChildren( + nodes: Array<{ + id: string | undefined; + parentId: string | null; + open: boolean; + __outsidePressBubbles: boolean; + }>, + id: string | undefined, +) { + console.log("nodes in getRaw", nodes); + console.log("id", id); + let allChildren = nodes.filter((node) => node.parentId === id && node.open); + console.log("all children", allChildren); + let currentChildren = allChildren; + + while (currentChildren.length) { + currentChildren = nodes.filter((node) => + currentChildren?.some((n) => node.parentId === n.id && node.open), + ); + + allChildren = allChildren.concat(currentChildren); + } + + return allChildren; +} + function getDeepestNode( nodes: Array>, id: string | undefined, @@ -47,4 +73,4 @@ function getDeepestNode( return nodes.find((node) => node.id === deepestNodeId); } -export { getChildren, getDeepestNode }; +export { getChildren, getDeepestNode, getRawChildren }; diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts index 6450f1fa..5777eaa4 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts @@ -13,6 +13,8 @@ import { userEvent } from "@testing-library/user-event"; import ThirdParty from "./wrapper-components/use-dismiss/third-party.svelte"; import DismissNestedPopovers from "./wrapper-components/use-dismiss/dismiss-nested-popovers.svelte"; import DismissPortaledChildren from "./wrapper-components/use-dismiss/dismiss-portaled-children.svelte"; +import { normalizeProp } from "../../../src/index.js"; +import DismissNestedNested from "./wrapper-components/use-dismiss/dismiss-nested-nested.svelte"; describe("true", () => { it("dismisses with escape key", async () => { @@ -182,4 +184,97 @@ describe("false", () => { expect(screen.queryByTestId("portaled-button")).toBeInTheDocument(), ); }); + + it("respects outsidePress function guard", async () => { + render(Dismiss, { outsidePress: () => true }); + await userEvent.click(document.body); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); +}); + +describe("bubbles", () => { + describe("prop resolution", () => { + it("undefined", () => { + const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = + normalizeProp(); + + expect(escapeKeyBubbles).toBe(false); + expect(outsidePressBubbles).toBe(true); + }); + + it("false", () => { + const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = + normalizeProp(false); + + expect(escapeKeyBubbles).toBe(false); + expect(outsidePressBubbles).toBe(false); + }); + + it("{}", () => { + const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = + normalizeProp({}); + + expect(escapeKeyBubbles).toBe(false); + expect(outsidePressBubbles).toBe(true); + }); + + it("{ escapeKey: false }", () => { + const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = + normalizeProp({ + escapeKey: false, + }); + + expect(escapeKeyBubbles).toBe(false); + expect(outsidePressBubbles).toBe(true); + }); + + it("{ outsidePress: false }", () => { + const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = + normalizeProp({ + outsidePress: false, + }); + + expect(escapeKeyBubbles).toBe(false); + expect(outsidePressBubbles).toBe(false); + }); + }); + + describe("outsidePress", () => { + it("true", async () => { + render(DismissNestedNested); + + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + expect(screen.queryByTestId("inner")).toBeInTheDocument(); + + await fireEvent.pointerDown(document.body); + + expect(screen.queryByTestId("outer")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(); + }); + + it("false", async () => { + render(DismissNestedNested, { outsidePress: [false, false] }); + + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + expect(screen.queryByTestId("inner")).toBeInTheDocument(); + + await fireEvent.pointerDown(document.body); + + await waitFor(() => + expect(screen.queryByTestId("outer")).toBeInTheDocument(), + ); + await waitFor(() => + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(), + ); + + await fireEvent.pointerDown(document.body); + + await waitFor(() => + expect(screen.queryByTestId("outer")).not.toBeInTheDocument(), + ); + await waitFor(() => + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(), + ); + }); + }); }); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte new file mode 100644 index 00000000..e39d67fe --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte @@ -0,0 +1,48 @@ + + + + + {#if open} + +
    + this is my content here for {testId} + + {@render children()} +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-dialog.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-dialog.svelte new file mode 100644 index 00000000..de5e63b5 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-dialog.svelte @@ -0,0 +1,22 @@ + + +{#if parentId == null} + + + +{:else} + +{/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte new file mode 100644 index 00000000..561bf397 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte @@ -0,0 +1,19 @@ + + + + + + + diff --git a/packages/floating-ui-svelte/test/visual/components/new/main.svelte b/packages/floating-ui-svelte/test/visual/components/new/main.svelte index 5fcdf6bf..7bbd5898 100644 --- a/packages/floating-ui-svelte/test/visual/components/new/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/new/main.svelte @@ -1,91 +1,21 @@ - -{#if open} -
    -
      - {#each items as item, index (item)} -
    • { - inputValue = item; - open = false; - f.domReference?.focus(); - }, - })}> - {item} -
    • - {/each} -
    -
    -{/if} -
    {activeIndex}
    + + + + + diff --git a/packages/floating-ui-svelte/test/visual/components/popover/popover-impl.svelte b/packages/floating-ui-svelte/test/visual/components/popover/popover-impl.svelte new file mode 100644 index 00000000..f4f2627c --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/popover/popover-impl.svelte @@ -0,0 +1,106 @@ + + + + + + {@render children?.( + ref, + ints.getReferenceProps({ "data-open": open ? "" : undefined }) + )} + + {#if open} + + +
    + {@render content({ + labelId, + descriptionId, + close: () => (open = false), + })} +
    +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/visual/components/popover/popover.svelte b/packages/floating-ui-svelte/test/visual/components/popover/popover.svelte index 5ff7d24f..380ede00 100644 --- a/packages/floating-ui-svelte/test/visual/components/popover/popover.svelte +++ b/packages/floating-ui-svelte/test/visual/components/popover/popover.svelte @@ -1,102 +1,19 @@ - - {@render children?.( - ref, - ints.getReferenceProps({ "data-open": open ? "" : undefined }) - )} - - {#if open} - - -
    - {@render content({ - labelId, - descriptionId, - close: () => (open = false), - })} -
    -
    -
    - {/if} -
    + +{#if parentId === null} + + + +{:else} + +{/if} From 40003b0cbd98a7050ef606784e2474fbac79435c Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 26 Jan 2025 21:53:22 -0500 Subject: [PATCH 71/87] fix get children --- packages/floating-ui-svelte/src/internal/get-children.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/floating-ui-svelte/src/internal/get-children.ts b/packages/floating-ui-svelte/src/internal/get-children.ts index acdacf5e..e5ec448f 100644 --- a/packages/floating-ui-svelte/src/internal/get-children.ts +++ b/packages/floating-ui-svelte/src/internal/get-children.ts @@ -5,7 +5,7 @@ function getChildren( id: string | undefined, ) { let allChildren = nodes.filter( - (node) => node.parentId === id && node.context?.data?.open, + (node) => node.parentId === id && node.context?.open, ); let currentChildren = allChildren; From 67c3797df0c6890839bb2e8868129dbc249bce87 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 26 Jan 2025 22:22:44 -0500 Subject: [PATCH 72/87] todo: capture tests --- .../floating-focus-manager.test.ts | 4 +- .../test/unit/hooks/use-dismiss.test.ts | 167 +++++++++++++++++- .../test/unit/hooks/use-typeahead.test.ts | 7 +- .../use-dismiss/dismiss-capture-dialog.svelte | 44 +++++ .../dismiss-capture-dialogs-multi.svelte | 25 +++ .../dismiss-capture-nested-dialog.svelte | 22 +++ .../use-dismiss/dismiss-nested-nested.svelte | 36 +++- .../use-dismiss/dismiss-overlay.svelte | 19 ++ .../dismiss-without-floating-tree.svelte | 49 +++++ .../test/visual/components/new/main.svelte | 12 +- 10 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialogs-multi.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-nested-dialog.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte create mode 100644 packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts index cf0d7d73..6a0632f9 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts @@ -172,7 +172,9 @@ describe("guards", () => { await userEvent.tab(); await userEvent.tab(); - expect(document.activeElement).toHaveAttribute("data-floating-ui-inert"); + await waitFor(() => + expect(document.activeElement).toHaveAttribute("data-floating-ui-inert"), + ); }); }); diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts index 5777eaa4..81f65a5e 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts @@ -6,15 +6,16 @@ import { waitFor, } from "@testing-library/svelte"; import { describe, expect, it, vi } from "vitest"; -import App from "./wrapper-components/use-dismiss.svelte"; import Dismiss from "./wrapper-components/use-dismiss/dismiss.svelte"; -import { sleep } from "../../utils.js"; +import { sleep, testKbd } from "../../utils.js"; import { userEvent } from "@testing-library/user-event"; import ThirdParty from "./wrapper-components/use-dismiss/third-party.svelte"; import DismissNestedPopovers from "./wrapper-components/use-dismiss/dismiss-nested-popovers.svelte"; import DismissPortaledChildren from "./wrapper-components/use-dismiss/dismiss-portaled-children.svelte"; import { normalizeProp } from "../../../src/index.js"; import DismissNestedNested from "./wrapper-components/use-dismiss/dismiss-nested-nested.svelte"; +import DismissWithoutFloatingTree from "./wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte"; +import DismissCaptureDialogsMulti from "./wrapper-components/use-dismiss/dismiss-capture-dialogs-multi.svelte"; describe("true", () => { it("dismisses with escape key", async () => { @@ -276,5 +277,167 @@ describe("bubbles", () => { expect(screen.queryByTestId("inner")).not.toBeInTheDocument(), ); }); + + it("mixed", async () => { + render(DismissNestedNested, { outsidePress: [true, false] }); + + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + expect(screen.queryByTestId("inner")).toBeInTheDocument(); + + await fireEvent.pointerDown(document.body); + + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(); + + await fireEvent.pointerDown(document.body); + + expect(screen.queryByTestId("outer")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(); + }); + }); + + describe("escapeKey", () => { + it("without FloatingTree", async () => { + render(DismissWithoutFloatingTree); + + screen.getByTestId("focus-button").focus(); + await waitFor(() => + expect(screen.queryByRole("tooltip")).toBeInTheDocument(), + ); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).toBeInTheDocument(); + }); + + it("true", async () => { + render(DismissNestedNested, { bubbles: true }); + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + expect(screen.queryByTestId("inner")).toBeInTheDocument(); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.queryByTestId("outer")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(); + }); + + it("false", async () => { + render(DismissNestedNested, { escapeKey: [false, false] }); + + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + expect(screen.queryByTestId("inner")).toBeInTheDocument(); + + await sleep(30); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(); + }); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.queryByTestId("outer")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(); + }); + + it("mixed", async () => { + render(DismissNestedNested, { escapeKey: [true, false] }); + + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + expect(screen.queryByTestId("inner")).toBeInTheDocument(); + await sleep(30); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.queryByTestId("outer")).toBeInTheDocument(); + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(); + + await userEvent.keyboard(testKbd.ESCAPE); + + expect(screen.queryByTestId("outer")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inner")).not.toBeInTheDocument(); + }); + }); +}); + +describe("capture", () => { + describe("prop resolution", () => { + it("undefined", () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = + normalizeProp(); + + expect(escapeKeyCapture).toBe(false); + expect(outsidePressCapture).toBe(true); + }); + + it("{}", () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = + normalizeProp({}); + + expect(escapeKeyCapture).toBe(false); + expect(outsidePressCapture).toBe(true); + }); + + it("true", () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = + normalizeProp(true); + + expect(escapeKeyCapture).toBe(true); + expect(outsidePressCapture).toBe(true); + }); + + it("false", () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = + normalizeProp(false); + + expect(escapeKeyCapture).toBe(false); + expect(outsidePressCapture).toBe(false); + }); + + it("{ escapeKey: true }", () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = + normalizeProp({ + escapeKey: true, + }); + + expect(escapeKeyCapture).toBe(true); + expect(outsidePressCapture).toBe(true); + }); + + it("{ outsidePress: false }", () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = + normalizeProp({ + outsidePress: false, + }); + + expect(escapeKeyCapture).toBe(false); + expect(outsidePressCapture).toBe(false); + }); + }); + + describe("outsidePress", () => { + it("false", async () => { + const user = userEvent.setup(); + + render(DismissCaptureDialogsMulti, { outsidePress: [false, false] }); + + expect(screen.getByText("outer")).toBeInTheDocument(); + expect(screen.getByText("inner")).toBeInTheDocument(); + + await sleep(30); + + await user.click(screen.getByText("outer")); + + expect(screen.getByText("outer")).toBeInTheDocument(); + expect(screen.getByText("inner")).toBeInTheDocument(); + + await user.click(screen.getByText("outside")); + + expect(screen.getByText("outer")).toBeInTheDocument(); + expect(screen.getByText("inner")).toBeInTheDocument(); + }); }); }); diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts index 4438d35b..6ab6dc6a 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts @@ -162,7 +162,7 @@ it("Menu - skips disabled items and opens submenu on space if no match", async ( }); it("Menu - resets once a match is no longer found", async () => { - vi.useRealTimers(); + vi.useFakeTimers({ shouldAdvanceTime: true }); render(Menu); @@ -172,7 +172,7 @@ it("Menu - resets once a match is no longer found", async () => { await userEvent.keyboard("undr"); - await waitFor(() => expect(screen.getByText("Undo")).toHaveFocus()); + expect(screen.getByText("Undo")).toHaveFocus(); await userEvent.keyboard("r"); @@ -180,11 +180,10 @@ it("Menu - resets once a match is no longer found", async () => { }); it("typing spaces on
    references does not open the menu", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); const spy = vi.fn(); render(Select, { onMatch: spy }); - vi.useFakeTimers({ shouldAdvanceTime: true }); - await userEvent.click(screen.getByRole("combobox")); await userEvent.keyboard("h"); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte new file mode 100644 index 00000000..ecc2c78f --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte @@ -0,0 +1,44 @@ + + + + + {#if open} + + +
    + {id} + {@render children?.()} +
    +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialogs-multi.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialogs-multi.svelte new file mode 100644 index 00000000..2dbb1b7a --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialogs-multi.svelte @@ -0,0 +1,25 @@ + + + + + + {null} + + + diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-nested-dialog.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-nested-dialog.svelte new file mode 100644 index 00000000..3ad8bc31 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-nested-dialog.svelte @@ -0,0 +1,22 @@ + + +{#if parentId == null} + + + +{:else} + +{/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte index 561bf397..1895950f 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte @@ -1,19 +1,41 @@ + bubbles={bubbles === true ? true : firstBubbles}> + bubbles={bubbles === true ? true : secondBubbles}> diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte new file mode 100644 index 00000000..9ab4abfe --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte @@ -0,0 +1,19 @@ + + + +
    e.stopPropagation()} + onkeydown={(e) => { + if (e.key === "Escape") { + e.stopPropagation(); + } + }}> + outside + {@render children?.()} +
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte new file mode 100644 index 00000000..59efc2ff --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte @@ -0,0 +1,49 @@ + + + +{#if popoverOpen} +
    + +
    +{/if} +{#if tooltipOpen} +
    +
    +{/if} diff --git a/packages/floating-ui-svelte/test/visual/components/new/main.svelte b/packages/floating-ui-svelte/test/visual/components/new/main.svelte index 7bbd5898..f8dfc571 100644 --- a/packages/floating-ui-svelte/test/visual/components/new/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/new/main.svelte @@ -8,14 +8,14 @@ + bubbles={{ + escapeKey: false, + }}> + bubbles={{ + escapeKey: false, + }}> From bceeb27eefc80da8b918313113da8425750a7a6a Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 27 Jan 2025 11:16:10 -0500 Subject: [PATCH 73/87] hmm --- .../src/hooks/use-click.svelte.ts | 4 +- .../src/hooks/use-dismiss.svelte.ts | 70 +++++++++---------- .../src/internal/get-children.ts | 28 +------- .../floating-focus-manager.test.ts | 1 - .../test/unit/hooks/use-dismiss.test.ts | 8 +-- .../use-dismiss/dismiss-overlay.svelte | 5 +- .../test/visual/components/new/main.svelte | 10 +-- 7 files changed, 44 insertions(+), 82 deletions(-) diff --git a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts index e9eb5e4a..b7dc41d6 100644 --- a/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-click.svelte.ts @@ -114,9 +114,7 @@ function useClick( return; } - if (isMouseLikePointerType(pointerType, true) && ignoreMouse) { - return; - } + if (isMouseLikePointerType(pointerType, true) && ignoreMouse) return; if ( context.open && diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index 6333bc7e..4e5fa4b5 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -22,7 +22,6 @@ import { on } from "svelte/events"; import { extract } from "../internal/extract.js"; import { watch } from "../internal/watch.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; -import { untrack } from "svelte"; import { FLOATING_ID_ATTRIBUTE } from "../internal/attributes.js"; const bubbleHandlerKeys = { @@ -128,7 +127,7 @@ function useDismiss( ): ElementProps { const enabled = $derived(extract(opts.enabled, true)); const escapeKey = $derived(extract(opts.escapeKey, true)); - const unstable_outsidePress = $derived(opts.outsidePress ?? true); + const outsidePressProp = $derived(opts.outsidePress ?? true); const outsidePressEvent = $derived( extract(opts.outsidePressEvent, "pointerdown"), ); @@ -139,21 +138,19 @@ function useDismiss( const ancestorScroll = $derived(extract(opts.ancestorScroll, false)); const bubbles = $derived(extract(opts.bubbles)); const capture = $derived(extract(opts.capture)); + const tree = useFloatingTree(); + const outsidePressFn = $derived( - typeof unstable_outsidePress === "function" - ? unstable_outsidePress - : () => false, + typeof outsidePressProp === "function" ? outsidePressProp : () => false, ); const outsidePress = $derived( - typeof unstable_outsidePress === "function" - ? outsidePressFn - : unstable_outsidePress, + typeof outsidePressProp === "function" ? outsidePressFn : outsidePressProp, ); const bubbleOptions = $derived(normalizeProp(bubbles)); const captureOptions = $derived(normalizeProp(capture)); + let endedOrStartedInside = false; let isComposing = false; - const tree = useFloatingTree(); let insideTree = false; function closeOnEscapeKeyDown(event: KeyboardEvent) { @@ -304,7 +301,6 @@ function useDismiss( return; } - console.log("treeNodes", tree?.nodes); const children = tree ? getChildren(tree?.nodes, nodeId) : []; if (children.length > 0) { @@ -336,6 +332,7 @@ function useDismiss( } function closeOnPressOutsideCapture(event: MouseEvent) { + console.log("close on press outside capture", context.floatingId); const callback = () => { closeOnPressOutside(event); getTarget(event)?.removeEventListener(outsidePressEvent, callback); @@ -343,38 +340,37 @@ function useDismiss( getTarget(event)?.addEventListener(outsidePressEvent, callback); } - $effect.pre(() => { - if (!context.open || !enabled) return; - context.data.__escapeKeyBubbles = bubbleOptions.escapeKey; - context.data.__outsidePressBubbles = bubbleOptions.outsidePress; + function onScroll(event: Event) { + context.onOpenChange(false, event, "ancestor-scroll"); + } - let compositionTimeout = -1; + let compositionTimeout = -1; - const onScroll = (event: Event) => { - context.onOpenChange(false, event, "ancestor-scroll"); - }; + function handleCompositionStart() { + window.clearTimeout(compositionTimeout); + isComposing = true; + } - const handleCompositionStart = () => { - window.clearTimeout(compositionTimeout); - isComposing = true; - }; + function handleCompositionEnd() { + // Safari fires `compositionend` before `keydown`, so we need to wait + // until the next tick to set `isComposing` to `false`. + // https://bugs.webkit.org/show_bug.cgi?id=165004 + compositionTimeout = window.setTimeout( + () => { + isComposing = false; + }, + // 0ms or 1ms don't work in Safari. 5ms appears to consistently work. + // Only apply to WebKit for the test to remain 0ms. + isWebKit() ? 5 : 0, + ); + } - const handleCompositionEnd = () => { - // Safari fires `compositionend` before `keydown`, so we need to wait - // until the next tick to set `isComposing` to `false`. - // https://bugs.webkit.org/show_bug.cgi?id=165004 - compositionTimeout = window.setTimeout( - () => { - isComposing = false; - }, - // 0ms or 1ms don't work in Safari. 5ms appears to consistently work. - // Only apply to WebKit for the test to remain 0ms. - isWebKit() ? 5 : 0, - ); - }; + $effect.pre(() => { + if (!context.open || !enabled) return; + context.data.__escapeKeyBubbles = bubbleOptions.escapeKey; + context.data.__outsidePressBubbles = bubbleOptions.outsidePress; const doc = getDocument(context.floating); - const listenersToRemove: Array<() => void> = []; if (escapeKey) { @@ -446,7 +442,7 @@ function useDismiss( }; }); - watch([() => outsidePress, () => outsidePressEvent], () => { + watch.pre([() => outsidePress, () => outsidePressEvent], () => { insideTree = false; }); diff --git a/packages/floating-ui-svelte/src/internal/get-children.ts b/packages/floating-ui-svelte/src/internal/get-children.ts index e5ec448f..caecf814 100644 --- a/packages/floating-ui-svelte/src/internal/get-children.ts +++ b/packages/floating-ui-svelte/src/internal/get-children.ts @@ -22,32 +22,6 @@ function getChildren( return allChildren; } -function getRawChildren( - nodes: Array<{ - id: string | undefined; - parentId: string | null; - open: boolean; - __outsidePressBubbles: boolean; - }>, - id: string | undefined, -) { - console.log("nodes in getRaw", nodes); - console.log("id", id); - let allChildren = nodes.filter((node) => node.parentId === id && node.open); - console.log("all children", allChildren); - let currentChildren = allChildren; - - while (currentChildren.length) { - currentChildren = nodes.filter((node) => - currentChildren?.some((n) => node.parentId === n.id && node.open), - ); - - allChildren = allChildren.concat(currentChildren); - } - - return allChildren; -} - function getDeepestNode( nodes: Array>, id: string | undefined, @@ -73,4 +47,4 @@ function getDeepestNode( return nodes.find((node) => node.id === deepestNodeId); } -export { getChildren, getDeepestNode, getRawChildren }; +export { getChildren, getDeepestNode }; diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts index 6a0632f9..5c94ee3c 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts @@ -584,7 +584,6 @@ describe("Navigation", () => { render(Navigation); await userEvent.hover(screen.getByText("Product")); - // biome-ignore lint/style/noNonNullAssertion: await userEvent.click(screen.getByText("Close").parentElement!); await userEvent.keyboard(testKbd.TAB); await waitFor(() => expect(screen.getByText("Close")).toHaveFocus()); diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts index 81f65a5e..e06ec3c1 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts @@ -420,8 +420,6 @@ describe("capture", () => { describe("outsidePress", () => { it("false", async () => { - const user = userEvent.setup(); - render(DismissCaptureDialogsMulti, { outsidePress: [false, false] }); expect(screen.getByText("outer")).toBeInTheDocument(); @@ -429,12 +427,10 @@ describe("capture", () => { await sleep(30); - await user.click(screen.getByText("outer")); - + await userEvent.click(screen.getByText("outer")); expect(screen.getByText("outer")).toBeInTheDocument(); expect(screen.getByText("inner")).toBeInTheDocument(); - - await user.click(screen.getByText("outside")); + await userEvent.click(screen.getByText("outside")); expect(screen.getByText("outer")).toBeInTheDocument(); expect(screen.getByText("inner")).toBeInTheDocument(); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte index 9ab4abfe..0d84d439 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte @@ -8,7 +8,10 @@
    e.stopPropagation()} + onpointerdown={(e) => { + console.log("prop stopped"); + e.stopPropagation(); + }} onkeydown={(e) => { if (e.key === "Escape") { e.stopPropagation(); diff --git a/packages/floating-ui-svelte/test/visual/components/new/main.svelte b/packages/floating-ui-svelte/test/visual/components/new/main.svelte index f8dfc571..73e68e6a 100644 --- a/packages/floating-ui-svelte/test/visual/components/new/main.svelte +++ b/packages/floating-ui-svelte/test/visual/components/new/main.svelte @@ -6,15 +6,11 @@ }: { outsidePress?: [boolean, boolean] } = $props(); - + From 04c6e2997fb88706e0b71e48d6f641dc5783b788 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 27 Jan 2025 12:16:50 -0500 Subject: [PATCH 74/87] skip capture till we work out a solution --- .../test/unit/hooks/use-dismiss.test.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts index e06ec3c1..465d9c68 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts @@ -363,7 +363,20 @@ describe("bubbles", () => { }); }); -describe("capture", () => { +/** + * We don't have first party portal support with Svelte, meaning we lose the ability to + * test this scenario following the original floating-ui, where they call `e.stopPropagation()` + * on the component that wraps the portalled content, thus stopping it from reaching the portalled + * content. + * + * With our implementation, the event nevers goes through that wrapper component, since events don't + * bubble/propagate through the "component tree", but rather through the DOM tree. + * + * I've explored a few ideas around handling this via a proxy element that would be inserted in + * the DOM tree where the portal component is called vs rendered and then dispatching events back + * and forth but I need to spend some more time on it. + */ +describe.todo("capture", () => { describe("prop resolution", () => { it("undefined", () => { const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = @@ -437,3 +450,36 @@ describe("capture", () => { }); }); }); + +describe("outsidePressEvent click", () => { + it("does not close when dragging outside the floating element", async () => { + render(Dismiss, { outsidePressEvent: "click" }); + + const floatingEl = screen.getByRole("tooltip"); + await fireEvent.mouseDown(floatingEl); + await fireEvent.mouseUp(document.body); + expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + }); + + it("does not close when dragging inside the floating element", async () => { + render(Dismiss, { outsidePressEvent: "click" }); + + const floatingEl = screen.getByRole("tooltip"); + + await fireEvent.mouseDown(document.body); + await fireEvent.mouseUp(floatingEl); + expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + }); + + it("closes when dragging outside the floating element and then clicking outside", async () => { + render(Dismiss, { outsidePressEvent: "click" }); + const floatingEl = screen.getByRole("tooltip"); + await fireEvent.mouseDown(floatingEl); + await fireEvent.mouseUp(document.body); + // a click event will have fired before the proper "outside" click + await fireEvent.click(document.body); + await fireEvent.click(document.body); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); +}); From f210f9eb9a07e883f137a6bf546c676fed503ce3 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 27 Jan 2025 13:29:04 -0500 Subject: [PATCH 75/87] add select demo --- .../components/select/color-swatch.svelte | 11 ++ .../test/visual/components/select/context.ts | 13 ++ .../test/visual/components/select/main.svelte | 12 ++ .../components/select/select-option.svelte | 69 ++++++++ .../visual/components/select/select.svelte | 154 ++++++++++++++++++ .../test/visual/routes/select/+page.svelte | 5 + 6 files changed, 264 insertions(+) create mode 100644 packages/floating-ui-svelte/test/visual/components/select/color-swatch.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/select/context.ts create mode 100644 packages/floating-ui-svelte/test/visual/components/select/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/select/select-option.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/select/select.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/select/+page.svelte diff --git a/packages/floating-ui-svelte/test/visual/components/select/color-swatch.svelte b/packages/floating-ui-svelte/test/visual/components/select/color-swatch.svelte new file mode 100644 index 00000000..27c5f178 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/select/color-swatch.svelte @@ -0,0 +1,11 @@ + + + diff --git a/packages/floating-ui-svelte/test/visual/components/select/context.ts b/packages/floating-ui-svelte/test/visual/components/select/context.ts new file mode 100644 index 00000000..62929d11 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/select/context.ts @@ -0,0 +1,13 @@ +import type { useInteractions } from "../../../../src/index.js"; +import { Context } from "../../../../src/internal/context.js"; + +export interface SelectContextData { + getItemProps: ReturnType["getItemProps"]; + activeIndex: number | null; + selectedIndex: number | null; + isTyping: boolean; + setSelectedValue: (value: string, index: number) => void; + selectedValue: string; +} + +export const SelectContext = new Context("SelectContext"); diff --git a/packages/floating-ui-svelte/test/visual/components/select/main.svelte b/packages/floating-ui-svelte/test/visual/components/select/main.svelte new file mode 100644 index 00000000..5b9b8428 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/select/main.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/floating-ui-svelte/test/visual/components/select/select-option.svelte b/packages/floating-ui-svelte/test/visual/components/select/select-option.svelte new file mode 100644 index 00000000..04826a27 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/select/select-option.svelte @@ -0,0 +1,69 @@ + + + +
    { + onSelect(); + }, + // Handle keyboard select. + onkeydown: (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + onSelect(); + } + + // Only if not using typeahead. + if (event.key === " " && !ctx.isTyping) { + event.preventDefault(); + onSelect(); + } + }, + })}> + + {@render children?.()} + +
    diff --git a/packages/floating-ui-svelte/test/visual/components/select/select.svelte b/packages/floating-ui-svelte/test/visual/components/select/select.svelte new file mode 100644 index 00000000..7742728a --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/select/select.svelte @@ -0,0 +1,154 @@ + + +

    Select

    +
    +
    + + + +
    + + {#if open} + + +
    + {@render children?.()} +
    +
    +
    + {:else} + + {/if} +
    +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/select/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/select/+page.svelte new file mode 100644 index 00000000..3f67c5e8 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/select/+page.svelte @@ -0,0 +1,5 @@ + + +
    From 3d44195956f0c1146a87b72f5bf00189c46c4d55 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 27 Jan 2025 18:56:18 -0500 Subject: [PATCH 76/87] autocomplete --- .../src/hooks/use-dismiss.svelte.ts | 1 - .../src/hooks/use-role.svelte.ts | 2 +- .../src/internal/handle-guard-focus.ts | 11 +- .../components/modal-combobox.svelte | 2 +- .../use-dismiss/dismiss-overlay.svelte | 1 - .../use-list-navigation/main.svelte | 2 +- .../autocomplete/autocomplete-item.svelte | 24 ++ .../components/autocomplete/main.svelte | 257 ++++++++++++++++++ .../visual/routes/autocomplete/+page.svelte | 5 + 9 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 packages/floating-ui-svelte/test/visual/components/autocomplete/autocomplete-item.svelte create mode 100644 packages/floating-ui-svelte/test/visual/components/autocomplete/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/autocomplete/+page.svelte diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index 4e5fa4b5..873dd38a 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -332,7 +332,6 @@ function useDismiss( } function closeOnPressOutsideCapture(event: MouseEvent) { - console.log("close on press outside capture", context.floatingId); const callback = () => { closeOnPressOutside(event); getTarget(event)?.removeEventListener(outsidePressEvent, callback); diff --git a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts index 9e0a6244..e960613c 100644 --- a/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-role.svelte.ts @@ -70,7 +70,7 @@ function useRole( "aria-expanded": context.open ? "true" : "false", "aria-haspopup": ariaRole === "alertdialog" ? "dialog" : ariaRole, "aria-controls": context.open ? context.floatingId : undefined, - ...(ariaRole === "listbox" && { role: "combobox " }), + ...(ariaRole === "listbox" && { role: "combobox" }), ...(ariaRole === "menu" && { id: referenceId }), ...(ariaRole === "menu" && isNested && { role: "menuitem" }), ...(role === "select" && { "aria-autocomplete": "none" }), diff --git a/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts b/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts index 03e856aa..94c0106a 100644 --- a/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts +++ b/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts @@ -1,10 +1,13 @@ import { sleep } from "./sleep.js"; /** - * Guards have an `aria-hidden` attribute on them, as they should not be visible to screen readers, - * however, they are used as an intermediary to focus the real target. When they receive focus in - * chromium browsers, an error is logged informing that an `aria-hidden` element should not be - * focused. + * + * We apply the `aria-hidden` attribute to elements that should not be visible to screen readers + * under specific circumstances, mostly when in a "modal" context or when they are strictly for + * utility purposes, like the focus guards. + * + * When these elements receive focus before we can remove the aria-hidden attribute, we need to + * handle the focus in a way that does not cause an error to be logged. * * This function handles the focus of the guard element first by momentary removing the * `aria-hidden` attribute, focusing the guard (which will cause something else to focus), and then diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/modal-combobox.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/modal-combobox.svelte index 60b061e2..9217ca58 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/modal-combobox.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/modal-combobox.svelte @@ -30,7 +30,7 @@ bind:this={f.floating} {...ints.getFloatingProps()} data-testid="floating"> - +
    {/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte index 0d84d439..13a380db 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte @@ -9,7 +9,6 @@
    { - console.log("prop stopped"); e.stopPropagation(); }} onkeydown={(e) => { diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte index d0cf71a8..c815455f 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte @@ -41,7 +41,7 @@ role="option" data-testid={`item-${index}`} aria-selected={activeIndex === index} - tabIndex={-1} + tabindex={-1} {...ints.getItemProps()} bind:this={listRef[index]}> {str} diff --git a/packages/floating-ui-svelte/test/visual/components/autocomplete/autocomplete-item.svelte b/packages/floating-ui-svelte/test/visual/components/autocomplete/autocomplete-item.svelte new file mode 100644 index 00000000..a665af70 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/autocomplete/autocomplete-item.svelte @@ -0,0 +1,24 @@ + + + +
    + {@render children?.()} +
    diff --git a/packages/floating-ui-svelte/test/visual/components/autocomplete/main.svelte b/packages/floating-ui-svelte/test/visual/components/autocomplete/main.svelte new file mode 100644 index 00000000..9cc9708f --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/autocomplete/main.svelte @@ -0,0 +1,257 @@ + + + + +

    Autocomplete

    +
    + inputValue, + (v) => { + inputValue = v; + if (!open) { + open = true; + } + }} + class="border-2 p-2 rounded border-slate-300 focus:border-blue-500 outline-none" + placeholder="Enter fruit" + aria-autocomplete="list" + {...ints.getReferenceProps({ + onkeydown: (event: KeyboardEvent) => { + if ( + event.key === "Enter" && + activeIndex != null && + filteredItems[activeIndex] + ) { + inputValue = filteredItems[activeIndex]; + activeIndex = null; + open = false; + } + }, + })} /> + {#if open} + + +
    + {#each filteredItems as item, index (item)} + listRef[index], + (v) => (listRef[index] = v)} + {...ints.getItemProps({ + active: activeIndex === index, + onclick() { + inputValue = item; + open = false; + f.domReference?.focus(); + }, + })} + active={activeIndex === index}> + {item} + + {/each} +
    +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/autocomplete/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/autocomplete/+page.svelte new file mode 100644 index 00000000..184fc5a9 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/autocomplete/+page.svelte @@ -0,0 +1,5 @@ + + +
    From 3d7f6f48f5bc82c97c8d741b3a2ead2166052c73 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 27 Jan 2025 19:39:08 -0500 Subject: [PATCH 77/87] menubar demo --- .../composite/composite-item.svelte | 60 ++++ .../src/components/composite/composite.svelte | 301 ++++++++++++++++++ .../src/components/composite/context.ts | 8 + .../visually-hidden-dismiss.svelte | 2 +- .../src/components/floating-overlay.svelte | 2 +- packages/floating-ui-svelte/src/index.ts | 6 + packages/floating-ui-svelte/src/types.ts | 2 +- .../autocomplete/autocomplete-item.svelte | 2 +- .../components/emoji-picker/option.svelte | 2 +- .../visual/components/menu/menu-impl.svelte | 2 +- .../test/visual/components/menu/menu.svelte | 17 +- .../visual/components/menubar/main.svelte | 69 ++++ .../test/visual/routes/menubar/+page.svelte | 5 + 13 files changed, 467 insertions(+), 11 deletions(-) create mode 100644 packages/floating-ui-svelte/src/components/composite/composite-item.svelte create mode 100644 packages/floating-ui-svelte/src/components/composite/composite.svelte create mode 100644 packages/floating-ui-svelte/src/components/composite/context.ts create mode 100644 packages/floating-ui-svelte/test/visual/components/menubar/main.svelte create mode 100644 packages/floating-ui-svelte/test/visual/routes/menubar/+page.svelte diff --git a/packages/floating-ui-svelte/src/components/composite/composite-item.svelte b/packages/floating-ui-svelte/src/components/composite/composite-item.svelte new file mode 100644 index 00000000..2fa756a7 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/composite/composite-item.svelte @@ -0,0 +1,60 @@ + + + + +{#if render} + {@render render(mergedProps, boxedRef)} +{:else} + {@const { children, ...restMerged } = mergedProps} +
    + {@render children?.()} +
    +{/if} diff --git a/packages/floating-ui-svelte/src/components/composite/composite.svelte b/packages/floating-ui-svelte/src/components/composite/composite.svelte new file mode 100644 index 00000000..ad898d62 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/composite/composite.svelte @@ -0,0 +1,301 @@ + + + + + + {#if render} + {@render render?.(mergedProps, boxedRef)} + {:else} +
    + {@render mergedProps.children?.()} +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/src/components/composite/context.ts b/packages/floating-ui-svelte/src/components/composite/context.ts new file mode 100644 index 00000000..8de53829 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/composite/context.ts @@ -0,0 +1,8 @@ +import { Context } from "../../internal/context.js"; + +const CompositeContext = new Context<{ + activeIndex: number; + onNavigate: (index: number) => void; +}>("CompositeContext"); + +export { CompositeContext }; diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte index d731276e..6871941c 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/visually-hidden-dismiss.svelte @@ -5,7 +5,7 @@ interface VisuallyHiddenDismissProps extends HTMLButtonAttributes, - WithRef {} + Required> {} export type { VisuallyHiddenDismissProps }; diff --git a/packages/floating-ui-svelte/src/components/floating-overlay.svelte b/packages/floating-ui-svelte/src/components/floating-overlay.svelte index 411b0feb..0c6a808d 100644 --- a/packages/floating-ui-svelte/src/components/floating-overlay.svelte +++ b/packages/floating-ui-svelte/src/components/floating-overlay.svelte @@ -9,7 +9,7 @@ globalThis.fuiLockCleanup ??= { current: () => {} }; interface FloatingOverlayProps - extends Partial, + extends WithRef, HTMLAttributes { /** * Whether the overlay should lock scrolling on the document body. diff --git a/packages/floating-ui-svelte/src/index.ts b/packages/floating-ui-svelte/src/index.ts index 7c035a6a..573e47f5 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -26,6 +26,12 @@ export { default as FloatingDelayGroup } from "./components/floating-delay-group export * from "./components/floating-list/floating-list.svelte"; export { default as FloatingList } from "./components/floating-list/floating-list.svelte"; +export * from "./components/composite/composite-item.svelte"; +export { default as CompositeItem } from "./components/composite/composite-item.svelte"; + +export * from "./components/composite/composite.svelte"; +export { default as Composite } from "./components/composite/composite.svelte"; + /** * Hooks */ diff --git a/packages/floating-ui-svelte/src/types.ts b/packages/floating-ui-svelte/src/types.ts index 63440dce..69323462 100644 --- a/packages/floating-ui-svelte/src/types.ts +++ b/packages/floating-ui-svelte/src/types.ts @@ -97,7 +97,7 @@ interface WithRef { /** * A bindable reference to the element. */ - ref: T | null; + ref?: T | null; } export type { diff --git a/packages/floating-ui-svelte/test/visual/components/autocomplete/autocomplete-item.svelte b/packages/floating-ui-svelte/test/visual/components/autocomplete/autocomplete-item.svelte index a665af70..218d6cce 100644 --- a/packages/floating-ui-svelte/test/visual/components/autocomplete/autocomplete-item.svelte +++ b/packages/floating-ui-svelte/test/visual/components/autocomplete/autocomplete-item.svelte @@ -8,7 +8,7 @@ active, children, ...rest - }: WithRef & + }: Required & HTMLAttributes & { active: boolean } = $props(); diff --git a/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte b/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte index 84d97df1..43ee578e 100644 --- a/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte +++ b/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte @@ -17,7 +17,7 @@ active: boolean; selected: boolean; index: number; - } & Partial> = $props(); + } & WithRef = $props(); +
    + + {@render children?.()} + +
    diff --git a/packages/floating-ui-svelte/test/visual/components/listbox-focus/main.svelte b/packages/floating-ui-svelte/test/visual/components/listbox-focus/main.svelte new file mode 100644 index 00000000..54cf6a36 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/listbox-focus/main.svelte @@ -0,0 +1,11 @@ + + + + diff --git a/packages/floating-ui-svelte/test/visual/components/listbox-focus/option.svelte b/packages/floating-ui-svelte/test/visual/components/listbox-focus/option.svelte new file mode 100644 index 00000000..b2c8fa97 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/listbox-focus/option.svelte @@ -0,0 +1,39 @@ + + + diff --git a/packages/floating-ui-svelte/test/visual/components/menu-virtual/virtual-nested.svelte b/packages/floating-ui-svelte/test/visual/components/menu-virtual/virtual-nested.svelte new file mode 100644 index 00000000..9d4de753 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu-virtual/virtual-nested.svelte @@ -0,0 +1,31 @@ + + +
    + {ref.current?.getAttribute("data-testid")} +
    + + + + + + + + + + + + + + + + + + + + diff --git a/packages/floating-ui-svelte/test/visual/components/omnibox/context.ts b/packages/floating-ui-svelte/test/visual/components/omnibox/context.ts new file mode 100644 index 00000000..6f355167 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/omnibox/context.ts @@ -0,0 +1,12 @@ +import type { useInteractions } from "../../../../src/index.js"; +import { Context } from "../../../../src/internal/context.js"; + +interface SelectContextValue { + activeIndex: number | null; + getItemProps: ReturnType["getItemProps"]; +} + +const SelectContext = new Context("SelectContext"); + +export { SelectContext }; +export type { SelectContextValue }; diff --git a/packages/floating-ui-svelte/test/visual/components/omnibox/main.svelte b/packages/floating-ui-svelte/test/visual/components/omnibox/main.svelte new file mode 100644 index 00000000..f749715b --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/omnibox/main.svelte @@ -0,0 +1,168 @@ + + +

    Omnibox

    +
    + handleKeyDown(e), + onblur: handleOnBlur, + })} /> + {#if open} + +
    +
    +

    Recent

    + {#if hasOptions} + + {/if} +
    + {#if !hasOptions} +

    No recent searches.

    + {/if} + + {#each options as option, index (option)} + { + removedIndex = index; + options = options.filter((o) => o !== option); + }} + onclick={() => { + if (activeIndex === null || !f.domReference) { + return; + } + open = false; + isFocusEnabled = false; + const domRef = + f.domReference as HTMLInputElement; + domRef.value = options[activeIndex]; + }} /> + {/each} + +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/visual/components/omnibox/search-option.svelte b/packages/floating-ui-svelte/test/visual/components/omnibox/search-option.svelte new file mode 100644 index 00000000..1c2b8d08 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/omnibox/search-option.svelte @@ -0,0 +1,58 @@ + + +
    + {value} + +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/new/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/new/+page.svelte index fbf8a00a..ea735089 100644 --- a/packages/floating-ui-svelte/test/visual/routes/new/+page.svelte +++ b/packages/floating-ui-svelte/test/visual/routes/new/+page.svelte @@ -1,5 +1,5 @@
    diff --git a/packages/floating-ui-svelte/test/visual/routes/omnibox/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/omnibox/+page.svelte new file mode 100644 index 00000000..9eea3555 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/omnibox/+page.svelte @@ -0,0 +1,5 @@ + + +
    From 90751c434b3324934a9ab795f9617f25340e46b5 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 28 Jan 2025 21:16:48 -0500 Subject: [PATCH 85/87] smore --- .../floating-portal/floating-portal.svelte | 1 - .../src/hooks/use-client-point.svelte.ts | 11 +++---- .../src/hooks/use-dismiss.svelte.ts | 4 +-- .../src/hooks/use-focus.svelte.ts | 2 -- .../src/hooks/use-hover.svelte.ts | 1 - .../src/hooks/use-list-navigation.svelte.ts | 32 +++++++++---------- 6 files changed, 21 insertions(+), 30 deletions(-) diff --git a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte index 6c938334..06a26862 100644 --- a/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -5,7 +5,6 @@ PortalContext, useFloatingPortalNode, } from "./hooks.svelte.js"; - import { watch } from "../../internal/watch.svelte.js"; import { disableFocusInside, enableFocusInside, diff --git a/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts index 7f913e93..c492ae1c 100644 --- a/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts @@ -8,7 +8,6 @@ import { extract } from "../internal/extract.js"; import type { ContextData, MaybeGetter } from "../types.js"; import type { FloatingContext } from "./use-floating.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; -import { watch } from "../internal/watch.svelte.js"; function createVirtualElement( domElement: Element | null | undefined, @@ -214,12 +213,10 @@ function useClientPoint( pointerType = event.pointerType; } - watch( - () => listenerDeps, - () => { - return addListener(); - }, - ); + $effect(() => { + listenerDeps; + return addListener(); + }); $effect(() => { if (enabled && !context.floating) { diff --git a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts index 873dd38a..f295dcdb 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -20,7 +20,6 @@ import { useFloatingTree } from "../components/floating-tree/hooks.svelte.js"; import { getChildren } from "../internal/get-children.js"; import { on } from "svelte/events"; import { extract } from "../internal/extract.js"; -import { watch } from "../internal/watch.svelte.js"; import type { ElementProps } from "./use-interactions.svelte.js"; import { FLOATING_ID_ATTRIBUTE } from "../internal/attributes.js"; @@ -441,7 +440,8 @@ function useDismiss( }; }); - watch.pre([() => outsidePress, () => outsidePressEvent], () => { + $effect.pre(() => { + [outsidePress, outsidePressEvent]; insideTree = false; }); diff --git a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts index 58588657..31b76907 100644 --- a/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-focus.svelte.ts @@ -14,8 +14,6 @@ import type { FloatingContext } from "./use-floating.svelte.js"; import { on } from "svelte/events"; import { executeCallbacks } from "../internal/execute-callbacks.js"; import { extract } from "../internal/extract.js"; -import { watch } from "../internal/watch.svelte.js"; -import type { ElementProps } from "./use-interactions.svelte.js"; interface UseFocusOptions { /** diff --git a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts index abceffcc..0bbefef5 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -159,7 +159,6 @@ function useHover(context: FloatingContext, opts: UseHoverOptions = {}) { $effect(() => { if (!enabled) return; - return context.events.on("openchange", onOpenChange); }); diff --git a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts index 602742b3..bd75b79f 100644 --- a/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -357,24 +357,22 @@ function useListNavigation( // Ensure the parent floating element has focus when a nested child closes // to allow arrow key navigation to work after the pointer leaves the child. - watch.pre( - [() => enabled, () => context.floating, () => tree?.nodes, () => virtual], - () => { - if (!enabled || context.floating || !tree || virtual || !mounted) return; - - const nodes = tree.nodes; - const parent = nodes.find((node) => node.id === parentId)?.context - ?.floating; - const activeEl = activeElement(getDocument(context.floating)); - const treeContainsActiveEl = nodes.some( - (node) => node.context && contains(node.context.floating, activeEl), - ); - if (parent && !treeContainsActiveEl && isPointerModality) { - parent.focus({ preventScroll: true }); - } - }, - ); + $effect.pre(() => { + if (!enabled || context.floating || !tree || virtual || !mounted) return; + + const nodes = tree.nodes; + const parent = nodes.find((node) => node.id === parentId)?.context + ?.floating; + const activeEl = activeElement(getDocument(context.floating)); + const treeContainsActiveEl = nodes.some( + (node) => node.context && contains(node.context.floating, activeEl), + ); + + if (parent && !treeContainsActiveEl && isPointerModality) { + parent.focus({ preventScroll: true }); + } + }); watch.pre( [() => enabled, () => virtual, () => virtualItemRef?.current], From 5d7c50140be92a1a5d3682ad248799534fb7599c Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 1 Feb 2025 14:05:58 -0500 Subject: [PATCH 86/87] remove logs --- .../test/visual/components/menu-virtual/menu-impl.svelte | 2 +- .../test/visual/components/menu-virtual/menu-item.svelte | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-impl.svelte b/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-impl.svelte index 19f2acd2..cb607b13 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-impl.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-impl.svelte @@ -233,7 +233,7 @@ {...ints.getReferenceProps({ onkeydown(event: KeyboardEvent) { if (event.key === " " || event.key === "Enter") { - console.log("clicked", virtualItemRef.current); + // console.log("clicked", virtualItemRef.current); } }, })} /> diff --git a/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-item.svelte b/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-item.svelte index 32de0f36..5ad85f0b 100644 --- a/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-item.svelte +++ b/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-item.svelte @@ -30,10 +30,6 @@ const isActive = $derived(item.index === menu.activeIndex); const id = useId(); - $effect(() => { - console.log("menu active index", menu.activeIndex); - }); - const mergedRef = box.with( () => ref, (v) => { @@ -68,12 +64,10 @@ onmouseenter(event: MouseEvent & { currentTarget: HTMLElement }) { rest.onmouseenter?.(event); if (menu.allowHover && menu.open) { - console.log("setting allow hover"); menu.activeIndex = item.index; } }, onkeydown(event: KeyboardEvent & { currentTarget: HTMLElement }) { - console.log("handling keydown"); function closeParents(parent: MenuContextType | null) { if (!parent) return; parent.open = false; From c1f97d8244fba7c1e90e629b831c7a1fc8e94242 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 2 Feb 2025 13:32:36 -0500 Subject: [PATCH 87/87] functions --- .../src/components/floating-arrow.svelte | 10 +- .../components/floating-delay-group.svelte | 4 +- .../floating-focus-manager.svelte | 106 ++-- .../src/hooks/use-click.svelte.ts | 8 +- .../src/hooks/use-client-point.svelte.ts | 18 +- .../src/hooks/use-dismiss.svelte.ts | 36 +- .../src/hooks/use-floating-context.svelte.ts | 108 ++++ .../src/hooks/use-floating-options.svelte.ts | 163 ++++++ .../hooks/use-floating-root-context.svelte.ts | 190 ++++--- .../src/hooks/use-floating.svelte.ts | 525 ++++-------------- .../src/hooks/use-focus.svelte.ts | 21 +- .../src/hooks/use-hover.svelte.ts | 36 +- .../src/hooks/use-list-navigation.svelte.ts | 50 +- .../src/hooks/use-merge-refs.svelte.ts | 67 --- .../src/hooks/use-position.svelte.ts | 201 +++---- .../src/hooks/use-role.svelte.ts | 4 +- .../src/hooks/use-transition.svelte.ts | 8 +- .../src/hooks/use-typeahead.svelte.ts | 5 +- .../src/internal/position-context.ts | 9 - .../src/internal/snapshot.svelte.ts | 16 +- .../floating-ui-svelte/src/safe-polygon.ts | 14 +- packages/floating-ui-svelte/src/types.ts | 7 +- .../unit/components/floating-arrow.test.ts | 181 +++--- .../components/tooltip.svelte | 6 +- .../components/combobox.svelte | 8 +- .../components/connected-drawer.svelte | 2 +- .../components/connected.svelte | 4 +- .../components/dialog-fallback-ref.svelte | 10 +- .../components/dialog.svelte | 16 +- .../components/floating-fallback.svelte | 8 +- .../components/floating-wrapper.svelte | 4 +- .../components/hover.svelte | 4 +- .../components/keep-mounted.svelte | 4 +- .../components/main.svelte | 11 +- .../components/mixed-mod-dialog.svelte | 16 +- .../components/modal-combobox.svelte | 4 +- .../non-modal-floating-portal.svelte | 4 +- .../components/outside-nodes.svelte | 5 +- .../components/restore-focus.svelte | 4 +- .../components/toggle-disabled.svelte | 4 +- .../components/trapped-combobox.svelte | 4 +- .../components/untrapped-combobox.svelte | 4 +- .../floating-focus-manager.test.ts | 4 +- .../floating-portal/components/main.svelte | 4 +- .../floating-portal/floating-portal.test.ts | 1 - .../unit/hooks/use-merge-refs.test.svelte.ts | 41 -- .../hooks/wrapper-components/use-click.svelte | 4 +- .../use-client-point.svelte | 6 +- .../wrapper-components/use-dismiss.svelte | 4 +- .../use-dismiss/dismiss-capture-dialog.svelte | 7 +- .../use-dismiss/dismiss-dialog.svelte | 4 +- .../use-dismiss/dismiss-popover.svelte | 5 +- .../dismiss-portaled-children.svelte | 5 +- .../dismiss-without-floating-tree.svelte | 11 +- .../use-dismiss/dismiss.svelte | 9 +- .../use-dismiss/third-party.svelte | 8 +- .../hooks/wrapper-components/use-focus.svelte | 2 +- .../hooks/wrapper-components/use-hover.svelte | 4 +- .../use-list-navigation/autocomplete.svelte | 6 +- .../home-end-ignore.svelte | 10 +- .../use-list-navigation/main.svelte | 9 +- .../use-list-navigation/scheduled.svelte | 4 +- .../use-list-navigation/select.svelte | 6 +- .../hooks/wrapper-components/use-role.svelte | 4 +- .../use-typeahead/impl.svelte.ts | 2 +- .../use-typeahead/typeahead-combobox.svelte | 7 +- .../use-typeahead/typeahead-full.svelte | 6 +- .../use-typeahead/typeahead-select.svelte | 7 +- .../test/visual/components/arrow/demo.svelte | 4 +- .../components/autocomplete/main.svelte | 6 +- .../components/complex-grid/main.svelte | 4 +- .../visual/components/drawer/drawer.svelte | 6 +- .../components/emoji-picker/main.svelte | 4 +- .../test/visual/components/grid/main.svelte | 4 +- .../components/listbox-focus/listbox.svelte | 2 +- .../components/menu-virtual/menu-impl.svelte | 4 +- .../components/menu-virtual/menu-item.svelte | 2 +- .../visual/components/menu/menu-impl.svelte | 4 +- .../visual/components/menu/menu-item.svelte | 2 +- .../navigation/navigation-item.svelte | 4 +- .../visual/components/omnibox/main.svelte | 13 +- .../components/popover/popover-impl.svelte | 6 +- .../visual/components/select/select.svelte | 4 +- .../visual/components/tooltip/tooltip.svelte | 6 +- 84 files changed, 1040 insertions(+), 1124 deletions(-) create mode 100644 packages/floating-ui-svelte/src/hooks/use-floating-context.svelte.ts create mode 100644 packages/floating-ui-svelte/src/hooks/use-floating-options.svelte.ts delete mode 100644 packages/floating-ui-svelte/src/hooks/use-merge-refs.svelte.ts delete mode 100644 packages/floating-ui-svelte/src/internal/position-context.ts delete mode 100644 packages/floating-ui-svelte/test/unit/hooks/use-merge-refs.test.svelte.ts diff --git a/packages/floating-ui-svelte/src/components/floating-arrow.svelte b/packages/floating-ui-svelte/src/components/floating-arrow.svelte index f7ed42ac..0486017c 100644 --- a/packages/floating-ui-svelte/src/components/floating-arrow.svelte +++ b/packages/floating-ui-svelte/src/components/floating-arrow.svelte @@ -1,12 +1,11 @@ -{#if context.floating} +{#if context.elements.floating} ; @@ -83,7 +83,7 @@ * @see https://floating-ui-svelte.vercel.app/docs/FloatingDelayGroup */ function useDelayGroup( - context: FloatingContext | FloatingRootContext, + context: FloatingContextData | FloatingRootContext, options: UseDelayGroupOptions = {} ): DelayGroupState { const enabled = $derived.by(() => extract(options.enabled, true)); diff --git a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte index 4fd817cc..47f96174 100644 --- a/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -1,7 +1,6 @@
    open, onOpenChange: (v) => { open = v; @@ -20,20 +20,20 @@ (open = true)} /> {#if open}
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected-drawer.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected-drawer.svelte index c59f0440..c908d119 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected-drawer.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected-drawer.svelte @@ -20,7 +20,7 @@ -
    +
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected.svelte index 2cdc5cc8..0871262d 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected.svelte @@ -22,12 +22,12 @@ {#if open} -
    +
    Parent Floating {/if} {#if open} - -
    + +
    - + -
    +
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-wrapper.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-wrapper.svelte index 7bbc9822..efe01941 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-wrapper.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-wrapper.svelte @@ -19,7 +19,7 @@ {#if open}
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/keep-mounted.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/keep-mounted.svelte index c7fc639a..3194d588 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/keep-mounted.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/keep-mounted.svelte @@ -21,11 +21,11 @@
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/main.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/main.svelte index bb06e683..e057c466 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/main.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/main.svelte @@ -20,7 +20,7 @@ let open = $state(false); let inputRef = $state(null); - const floating = useFloating({ + const f = useFloating({ open: () => open, onOpenChange: (v) => { open = v; @@ -40,7 +40,7 @@ @@ -54,8 +54,11 @@ {...rest} initialFocus={initialFocus === "two" ? ref : initialFocus} returnFocus={returnFocus === "inputRef" ? inputRef : returnFocus} - context={floating.context}> -
    + context={f.context}> +
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/non-modal-floating-portal.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/non-modal-floating-portal.svelte index e81a61a9..36a34c31 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/non-modal-floating-portal.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/non-modal-floating-portal.svelte @@ -25,7 +25,7 @@ {#if open} @@ -33,7 +33,7 @@
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/outside-nodes.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/outside-nodes.svelte index 5161bb3b..e3fe0729 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/outside-nodes.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/outside-nodes.svelte @@ -19,7 +19,7 @@ (open = !open)} />
    @@ -31,6 +31,7 @@ context={floating.context} {outsideElementsInert} {modal}> -
    +
    +
    {/if} diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/restore-focus.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/restore-focus.svelte index 5700f21b..0ff2e55f 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/restore-focus.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/restore-focus.svelte @@ -22,13 +22,13 @@ {#if open}
    {#if removedIndex < 3} diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/toggle-disabled.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/toggle-disabled.svelte index 8ae2f10f..3dbd98d6 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/toggle-disabled.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/toggle-disabled.svelte @@ -23,7 +23,7 @@ @@ -34,7 +34,7 @@ {#if open}
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/trapped-combobox.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/trapped-combobox.svelte index da742d8b..cff00391 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/trapped-combobox.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/trapped-combobox.svelte @@ -27,14 +27,14 @@
    {#if open}
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/untrapped-combobox.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/untrapped-combobox.svelte index 53458982..119dc68b 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/untrapped-combobox.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/untrapped-combobox.svelte @@ -28,7 +28,7 @@
    @@ -39,7 +39,7 @@ initialFocus={-1} modal={false}>
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts index aeabe3fd..7de2fd64 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts @@ -609,7 +609,9 @@ describe("Navigation", () => { await userEvent.unhover(screen.getByTestId("subnavigation")); await userEvent.hover(screen.getByText("Product")); await userEvent.unhover(screen.getByText("Product")); - expect(screen.queryByTestId("subnavigation")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByTestId("subnavigation")).not.toBeInTheDocument(), + ); }); }); diff --git a/packages/floating-ui-svelte/test/unit/components/floating-portal/components/main.svelte b/packages/floating-ui-svelte/test/unit/components/floating-portal/components/main.svelte index bdfed475..60e97eaf 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-portal/components/main.svelte +++ b/packages/floating-ui-svelte/test/unit/components/floating-portal/components/main.svelte @@ -16,10 +16,10 @@ {#if open} -
    +
    {/if}
    diff --git a/packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts b/packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts index 7d1d3244..b4e39986 100644 --- a/packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts +++ b/packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts @@ -1,6 +1,5 @@ import { act, - cleanup, fireEvent, render, screen, diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-merge-refs.test.svelte.ts b/packages/floating-ui-svelte/test/unit/hooks/use-merge-refs.test.svelte.ts deleted file mode 100644 index a80e423a..00000000 --- a/packages/floating-ui-svelte/test/unit/hooks/use-merge-refs.test.svelte.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { useFloating } from "../../../src/index.js"; -import { withRunes } from "../../with-runes.svelte.js"; -import { useMergeRefs } from "../../../src/hooks/use-merge-refs.svelte.js"; - -describe("useMergeRefs", () => { - vi.mock(import("svelte"), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getContext: vi.fn().mockReturnValue(null), - }; - }); - - it( - "merges the references of multiple floating instances or other boxed elements", - withRunes(() => { - const ref1 = useFloating(); - const ref2 = useFloating(); - const ref3 = $state({ current: null }); - - const mergedRef = useMergeRefs([ref1, ref2, ref3]); - expect(mergedRef.current).toBe(null); - expect(ref1.reference).toBe(null); - expect(ref2.reference).toBe(null); - expect(ref3.current).toBe(null); - - const node = document.createElement("div"); - mergedRef.current = node; - expect(mergedRef.current).toBe(node); - expect(ref1.reference).toBe(node); - expect(ref2.reference).toBe(node); - expect(ref3.current).toBe(node); - mergedRef.current = null; - expect(mergedRef.current).toBe(null); - expect(ref1.reference).toBe(null); - expect(ref2.reference).toBe(null); - expect(ref3.current).toBe(null); - }), - ); -}); diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-click.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-click.svelte index 26540ce5..7ac5c6cb 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-click.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-click.svelte @@ -33,13 +33,13 @@ {#if open}
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-client-point.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-client-point.svelte index 6247a04f..1bf5f1c1 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-client-point.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-client-point.svelte @@ -35,7 +35,7 @@ const ints = useInteractions([clientPoint]); - const rect = $derived(f.reference?.getBoundingClientRect()); + const rect = $derived(f.elements.reference?.getBoundingClientRect());
    Reference
    {#if open}
    Floating
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss.svelte index fb4ece68..0901e158 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss.svelte @@ -27,7 +27,7 @@ @@ -35,7 +35,7 @@ {#if open}
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte index ecc2c78f..6b931b13 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte @@ -30,11 +30,14 @@ - + {#if open} -
    +
    {id} {@render children?.()}
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte index e39d67fe..6d7268fc 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte @@ -31,12 +31,12 @@ {#if open}
    this is my content here for {testId} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte index 662d2ba3..b2d3b0c6 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte @@ -29,13 +29,14 @@
    {@render children?.()}
    {/snippet} - + {#if open} {#if modal == null} {@render Dialog()} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-portaled-children.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-portaled-children.svelte index a1ce454b..d6d73678 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-portaled-children.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-portaled-children.svelte @@ -16,9 +16,10 @@ const ints = useInteractions([useDismiss(f.context)]); - + {#if open} -
    +
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte index 59efc2ff..7e7d9a66 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte @@ -27,23 +27,24 @@ ]); - + {#if popoverOpen}
    {/if} {#if tooltipOpen}
    {/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss.svelte index ceb44ae4..755e5f9e 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss.svelte @@ -32,10 +32,15 @@ const ints = useInteractions([useDismiss(f.context, props)]); - + {#if open} -
    +
    {/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/third-party.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/third-party.svelte index 427d2eb3..85f6e63d 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/third-party.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/third-party.svelte @@ -17,10 +17,14 @@ const ints = useInteractions([dismiss]); - + {#if open} -
    +
    {/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-focus.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-focus.svelte index 62aa86a1..63f22441 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-focus.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-focus.svelte @@ -27,7 +27,7 @@ diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-hover.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-hover.svelte index 26f960e1..655f095a 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-hover.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-hover.svelte @@ -39,7 +39,7 @@ {#if showReference} @@ -48,7 +48,7 @@ {#if open}
    diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte index c6393f7a..bccb3840 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/autocomplete.svelte @@ -50,7 +50,7 @@ {#if open}
    { inputValue = item; open = false; - f.domReference?.focus(); + f.elements.domReference?.focus(); }, })}> {item} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/home-end-ignore.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/home-end-ignore.svelte index 5c30a4d6..35593069 100644 --- a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/home-end-ignore.svelte +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/home-end-ignore.svelte @@ -25,9 +25,15 @@ - + {#if open} -
    +