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/biome.json b/biome.json index cc6b0797..fef776a3 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,24 @@ "rules": { "style": { "useConst": "off", - "useImportType": "off" + "useImportType": "off", + "noNonNullAssertion": "off" + }, + "suspicious": { + "noAssignInExpressions": "off" + } + } + } + }, + { + "include": ["*.ts"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noAssignInExpressions": "off" } } } diff --git a/package.json b/package.json index d1da242f..3a206a9a 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "monorepo", "private": true, "scripts": { + "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 test", - "test:watch": "pnpm --recursive test:watch", - "sync": "pnpm --recursive sync", - "postinstall": "pnpm sync" + "test": "pnpm --recursive --reporter append-only --color test", + "test:watch": "pnpm --recursive --reporter append-only --color test:watch", + "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 6dfa0f6f..f4ba5206 100644 --- a/packages/floating-ui-svelte/package.json +++ b/packages/floating-ui-svelte/package.json @@ -2,9 +2,12 @@ "name": "@skeletonlabs/floating-ui-svelte", "version": "0.3.9", "scripts": { + "dev": "pnpm \"/dev:/\"", "build": "svelte-package --input ./src", "build:watch": "pnpm build --watch", "test": "vitest run", + "dev:visual": "vite dev", + "dev:build": "pnpm build:watch", "test:watch": "pnpm test --watch", "sync": "svelte-kit sync && pnpm build" }, @@ -31,17 +34,26 @@ "@sveltejs/kit": "^2.15.1", "@sveltejs/package": "^2.3.7", "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/vite": "4.0.0-beta.9", "@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", - "svelte": "^5.16.0", + "lucide-svelte": "^0.469.0", + "resize-observer-polyfill": "^1.5.1", + "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": { "@floating-ui/dom": "^1.6.12", - "@floating-ui/utils": "^0.2.8" + "@floating-ui/utils": "^0.2.8", + "esm-env": "^1.2.1", + "style-to-object": "^1.0.8", + "tabbable": "^6.2.0" } } 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..cdbaaf52 --- /dev/null +++ b/packages/floating-ui-svelte/src/app.d.ts @@ -0,0 +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 fuiLockCount: { current: number }; + // biome-ignore lint/complexity/noBannedTypes: + var fuiLockCleanup: { current: Function }; + var fuiPrevFocusedElements: Element[]; +} + +export {}; 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..a76a8ba8 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/composite/composite-item.svelte @@ -0,0 +1,56 @@ + + + + +{#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-arrow.svelte b/packages/floating-ui-svelte/src/components/floating-arrow.svelte index fda2b9b0..0486017c 100644 --- a/packages/floating-ui-svelte/src/components/floating-arrow.svelte +++ b/packages/floating-ui-svelte/src/components/floating-arrow.svelte @@ -1,178 +1,197 @@ - - - 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} + - - - - - - \ No newline at end of file + + + + + + +{/if} diff --git a/packages/floating-ui-svelte/src/components/floating-delay-group.svelte b/packages/floating-ui-svelte/src/components/floating-delay-group.svelte new file mode 100644 index 00000000..7ac689de --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-delay-group.svelte @@ -0,0 +1,220 @@ + + + + +{@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 new file mode 100644 index 00000000..47f96174 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-focus-manager/floating-focus-manager.svelte @@ -0,0 +1,779 @@ + + + + +{#snippet DismissButton({ ref }: DismissButtonSnippetProps)} + {#if !disabled && visuallyHiddenDismiss && modal} + context.onOpenChange(false, event)}> + {typeof visuallyHiddenDismiss === "string" + ? visuallyHiddenDismiss + : "Dismiss"} + + {/if} +{/snippet} + +{#if shouldRenderGuards} + beforeGuardRef, (v) => (beforeGuardRef = v)} + onfocus={(event) => { + if (modal) { + afterSleep(0, () => { + 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)) { + afterSleep(0, () => { + const nextTabbable = + getNextTabbable() || context.elements.domReference; + nextTabbable?.focus(); + }); + } else { + handleGuardFocus(portalContext.beforeOutsideGuard); + } + } + }} /> +{/if} + +{#if !isUntrappedTypeableCombobox} + {@render DismissButton({ ref: startDismissButtonRef })} +{/if} +{@render children?.()} +{@render DismissButton({ ref: endDismissButtonRef })} +{#if shouldRenderGuards} + afterGuardRef, (v) => (afterGuardRef = v)} + onfocus={(event) => { + if (modal) { + afterSleep(0, () => { + enqueueFocus(getTabbableElements()[0]); + }); + } else if ( + portalContext?.preserveTabOrder && + portalContext.portalNode + ) { + if (closeOnFocusOut) { + preventReturnFocus = true; + } + + if (isOutsideEvent(event, portalContext.portalNode)) { + afterSleep(0, () => { + const prevTabbable = + getPreviousTabbable() || + context.elements.domReference; + + prevTabbable?.focus(); + }); + } else { + handleGuardFocus(portalContext.afterOutsideGuard); + } + } + }} /> +{/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 new file mode 100644 index 00000000..6871941c --- /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-list/floating-list.svelte b/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte new file mode 100644 index 00000000..c6eaa418 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-list/floating-list.svelte @@ -0,0 +1,79 @@ + + + + +{@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..794144e0 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-list/hooks.svelte.ts @@ -0,0 +1,91 @@ +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 { SvelteMap } from "svelte/reactivity"; + +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; +} + +/** + * 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.getOr({ + elements: [], + map: new SvelteMap(), + register: () => {}, + unregister: () => {}, + } as FloatingListContextType); + let index = $state(null); + let ref = $state(null); + + watch( + () => ref, + () => { + const node = ref; + return () => { + if (node) { + listContext.unregister(node); + } + }; + }, + ); + + $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); + } + const idx = node ? listContext.map.get(node) : null; + if (idx === undefined) return; + if (idx != null) { + index = idx; + } + if (idx === null) return; + + listContext.elements[idx] = node; + if (listContext.labels) { + if (label !== undefined) { + listContext.labels[idx] = label; + } else { + listContext.labels[idx] = node?.textContent ?? null; + } + } + }, + }; +} + +// function useListItem(opts: UseListItemOptions = {}) + +export { FloatingListContext, useListItem }; +export type { UseListItemOptions }; 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..0c6a808d --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-overlay.svelte @@ -0,0 +1,123 @@ + + + + +
+ {@render children?.()} +
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..06a26862 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-portal/floating-portal.svelte @@ -0,0 +1,214 @@ + + + + + + +{#if shouldRenderGuards && portalNode.current} + beforeOutsideGuard, (v) => (beforeOutsideGuard = v)} + onfocus={(event) => { + if (isOutsideEvent(event, portalNode.current)) { + handleGuardFocus(beforeInsideGuard); + } else { + afterSleep(0, () => { + const prevTabbable = + getPreviousTabbable() || + focusManagerState?.domReference; + prevTabbable?.focus(); + }); + } + }} /> +{/if} + +{#if shouldRenderGuards && portalNode.current} + +{/if} + +{#if portalNode.current} + +{/if} + +{#if shouldRenderGuards && portalNode.current} + afterOutsideGuard, (v) => (afterOutsideGuard = v)} + onfocus={(event) => { + if (isOutsideEvent(event, portalNode.current)) { + handleGuardFocus(afterInsideGuard); + } else { + afterSleep(0, () => { + const nextTabbable = + getNextTabbable() || focusManagerState?.domReference; + + nextTabbable?.focus(); + + if (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..03c10280 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-portal/hooks.svelte.ts @@ -0,0 +1,121 @@ +import { useId } from "../../hooks/use-id.js"; +import { ORIGIN_ID_ATTRIBUTE } from "../../internal/attributes.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; + beforeInsideGuard: HTMLSpanElement | null; + afterInsideGuard: HTMLSpanElement | null; + beforeOutsideGuard: HTMLSpanElement | null; + afterOutsideGuard: HTMLSpanElement | null; +} | null>("PortalContext"); + +const attr = createAttribute("portal"); + +function usePortalContext() { + return PortalContext.getOr(null); +} + +interface UseFloatingPortalNodeProps { + id?: MaybeGetter; + 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(); + // 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; + }); + }; + }); + + watch.pre( + () => 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.pre( + [() => 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/floating-tree/floating-node.svelte b/packages/floating-ui-svelte/src/components/floating-tree/floating-node.svelte new file mode 100644 index 00000000..280bf521 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-tree/floating-node.svelte @@ -0,0 +1,30 @@ + + + + +{@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..940a8344 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-tree/floating-tree.svelte @@ -0,0 +1,36 @@ + + + + +{@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..4084a73d --- /dev/null +++ b/packages/floating-ui-svelte/src/components/floating-tree/hooks.svelte.ts @@ -0,0 +1,64 @@ +import { useId } from "../../hooks/use-id.js"; +import { Context } from "../../internal/context.js"; +import type { + FloatingNodeType, + FloatingTreeType, + ReferenceType, +} from "../../types.js"; +import { untrack } from "svelte"; + +const FloatingNodeContext = new Context( + "FloatingNodeContext", +); + +const FloatingTreeContext = new Context( + "FloatingTreeContext", +); + +/** + * Returns the parent node id for nested floating elements, if available. + * Returns `null` for top-level floating elements. + */ +function useFloatingParentNodeId(): string | null { + return FloatingNodeContext.getOr(null)?.id || null; +} + +/** + * Returns the nearest floating tree context, if available. + */ +function useFloatingTree< + RT extends ReferenceType = ReferenceType, +>(): FloatingTreeType | null { + return FloatingTreeContext.getOr(null) as FloatingTreeType | null; +} + +/** + * Registers a node into the `FloatingTree`, returning its id. + * @see https://floating-ui-svelte.vercel.app/docs/api/use-floating-node-id + */ +function useFloatingNodeId(customParentId?: string): string | undefined { + const id = useId(); + const tree = useFloatingTree(); + const _parentId = useFloatingParentNodeId(); + const parentId = customParentId || _parentId; + + $effect.pre(() => { + return untrack(() => { + const node = { id, parentId }; + tree?.addNode(node); + return () => { + tree?.removeNode(node); + }; + }); + }); + + return id; +} + +export { + useFloatingNodeId, + useFloatingParentNodeId, + useFloatingTree, + FloatingNodeContext, + FloatingTreeContext, +}; 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..90f0bf47 --- /dev/null +++ b/packages/floating-ui-svelte/src/components/focus-guard.svelte @@ -0,0 +1,79 @@ + + + + + + {@render children?.()} + 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..e3369426 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,10 @@ 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 { isMouseLikePointerType, isPointerType } from "../internal/dom.js"; +import { isTypeableElement } from "../internal/is-typeable-element.js"; +import type { MaybeGetter, ReferenceType } from "../types.js"; +import { extract } from "../internal/extract.js"; import type { ElementProps } from "./use-interactions.svelte.js"; +import type { FloatingContextData } from "./use-floating-context.svelte.js"; interface UseClickOptions { /** @@ -10,20 +12,20 @@ interface UseClickOptions { * handlers. * @default true */ - enabled?: boolean; + enabled?: MaybeGetter; /** * The type of event to use to determine a “click” with mouse input. * Keyboard clicks work as normal. * @default 'click' */ - event?: "click" | "mousedown"; + event?: MaybeGetter<"click" | "mousedown">; /** * Whether to toggle the open state with repeated clicks. * @default true */ - toggle?: boolean; + toggle?: MaybeGetter; /** * Whether to ignore the logic for mouse input (for example, if `useHover()` @@ -33,7 +35,7 @@ interface UseClickOptions { * even once the cursor leaves. This may be not be desirable in some cases. * @default false */ - ignoreMouse?: boolean; + ignoreMouse?: MaybeGetter; /** * Whether to add keyboard handlers (Enter and Space key functionality) for @@ -41,139 +43,146 @@ interface UseClickOptions { * “click”). * @default true */ - keyboardHandlers?: boolean; + keyboardHandlers?: MaybeGetter; + + /** + * 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?: MaybeGetter; } function isButtonTarget(event: KeyboardEvent) { return isHTMLElement(event.target) && event.target.tagName === "BUTTON"; } -function isSpaceIgnored(element: Element | null) { +function isSpaceIgnored(element: ReferenceType | null) { return isTypeableElement(element); } +const pointerTypes = ["mouse", "pen", "touch"] as const; + +type PointerType = (typeof pointerTypes)[number]; + function useClick( - context: FloatingContext, - options: UseClickOptions = {}, + context: FloatingContextData, + opts: UseClickOptions = {}, ): ElementProps { - const { - open, - onOpenChange, - data, - elements: { reference }, - } = $derived(context); - - const { - enabled = true, - event: eventOption = "click", - toggle = true, - ignoreMouse = false, - keyboardHandlers = true, - } = $derived(options); - - let pointerType: PointerEvent["pointerType"] | undefined = undefined; + 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; + pointerType = event.pointerType; + } + + 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 (eventOption === "click") return; + if (isMouseLikePointerType(pointerType, true) && ignoreMouse) { + return; + } + + if ( + context.open && + toggle && + (context.data.openEvent && stickIfOpen + ? context.data.openEvent.type === "mousedown" + : true) + ) { + context.onOpenChange(false, event, "click"); + } else { + // Prevent stealing focus from the floating element + event.preventDefault(); + context.onOpenChange(true, event, "click"); + } + } + + function onclick(event: MouseEvent) { + if (eventOption === "mousedown" && pointerType) { + pointerType = undefined; + return; + } + + if (isMouseLikePointerType(pointerType, true) && ignoreMouse) return; + + if ( + context.open && + toggle && + (context.data.openEvent && stickIfOpen + ? context.data.openEvent.type === "click" + : true) + ) { + context.onOpenChange(false, event, "click"); + } else { + context.onOpenChange(true, event, "click"); + } + } + + function onkeydown(event: KeyboardEvent) { + pointerType = undefined; + + if (event.defaultPrevented || !keyboardHandlers || isButtonTarget(event)) { + return; + } + + if (event.key === " " && !isSpaceIgnored(context.elements.domReference)) { + // Prevent scrolling + event.preventDefault(); + didKeyDown = true; + } + + if (event.key === "Enter") { + if (context.open && toggle) { + context.onOpenChange(false, event, "click"); + } else { + context.onOpenChange(true, event, "click"); + } + } + } + + function onkeyup(event: KeyboardEvent) { + if ( + event.defaultPrevented || + !keyboardHandlers || + isButtonTarget(event) || + isSpaceIgnored(context.elements.domReference) + ) { + return; + } + + if (event.key === " " && didKeyDown) { + didKeyDown = false; + if (context.open && toggle) { + context.onOpenChange(false, event, "click"); + } else { + context.onOpenChange(true, event, "click"); + } + } + } + + const reference = $derived({ + onpointerdown: onpointerdown, + onmousedown: onmousedown, + onclick: onclick, + onkeydown: onkeydown, + onkeyup: onkeyup, + }); + return { get reference() { - if (!enabled) { - return {}; - } - return { - onpointerdown: (event: PointerEvent) => { - pointerType = event.pointerType; - }, - onmousedown: (event: MouseEvent) => { - if (event.button !== 0) { - return; - } - - if (isMouseLikePointerType(pointerType, true) && ignoreMouse) { - return; - } - - if (eventOption === "click") { - return; - } - - if ( - open && - toggle && - (data.openEvent ? data.openEvent.type === "mousedown" : true) - ) { - onOpenChange(false, event, "click"); - } else { - // Prevent stealing focus from the floating element - event.preventDefault(); - onOpenChange(true, event, "click"); - } - }, - onclick: (event: MouseEvent) => { - if (eventOption === "mousedown" && pointerType) { - pointerType = undefined; - return; - } - - if (isMouseLikePointerType(pointerType, true) && ignoreMouse) { - return; - } - - if ( - open && - toggle && - (data.openEvent ? data.openEvent.type === "click" : true) - ) { - onOpenChange(false, event, "click"); - } else { - onOpenChange(true, event, "click"); - } - }, - onkeydown: (event: KeyboardEvent) => { - pointerType = undefined; - - if ( - event.defaultPrevented || - !keyboardHandlers || - isButtonTarget(event) - ) { - return; - } - // @ts-expect-error FIXME - if (event.key === " " && !isSpaceIgnored(reference)) { - // Prevent scrolling - event.preventDefault(); - didKeyDown = true; - } - - if (event.key === "Enter") { - if (open && toggle) { - onOpenChange(false, event, "click"); - } else { - onOpenChange(true, event, "click"); - } - } - }, - onkeyup: (event: KeyboardEvent) => { - if ( - event.defaultPrevented || - !keyboardHandlers || - isButtonTarget(event) || - // @ts-expect-error FIXME - isSpaceIgnored(reference) - ) { - return; - } - - if (event.key === " " && didKeyDown) { - didKeyDown = false; - if (open && toggle) { - onOpenChange(false, event, "click"); - } else { - onOpenChange(true, event, "click"); - } - } - }, - }; + if (!enabled) return {}; + return reference; }, }; } 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 new file mode 100644 index 00000000..92e9af79 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-client-point.svelte.ts @@ -0,0 +1,270 @@ +import { getWindow } from "@floating-ui/utils/dom"; +import { + contains, + getTarget, + isMouseLikePointerType, +} from "../internal/dom.js"; +import { extract } from "../internal/extract.js"; +import type { ContextData, MaybeGetter } from "../types.js"; +import type { ElementProps } from "./use-interactions.svelte.js"; +import type { FloatingContextData } from "./use-floating-context.svelte.js"; + +function createVirtualElement( + domElement: Element | null | undefined, + data: { + axis: "x" | "y" | "both"; + data: ContextData; + pointerType: string | undefined; + x: number | null; + y: number | null; + }, +) { + let offsetX: number | null = null; + let offsetY: number | null = null; + let isAutoUpdateEvent = false; + + return { + contextElement: domElement || undefined, + getBoundingClientRect() { + const domRect = domElement?.getBoundingClientRect() || { + width: 0, + height: 0, + x: 0, + y: 0, + }; + + const isXAxis = data.axis === "x" || data.axis === "both"; + const isYAxis = data.axis === "y" || data.axis === "both"; + const canTrackCursorOnAutoUpdate = + ["mouseenter", "mousemove"].includes(data.data.openEvent?.type || "") && + data.pointerType !== "touch"; + + let width = domRect.width; + let height = domRect.height; + let x = domRect.x; + let y = domRect.y; + + if (offsetX == null && data.x && isXAxis) { + offsetX = domRect.x - data.x; + } + + if (offsetY == null && data.y && isYAxis) { + offsetY = domRect.y - data.y; + } + + x -= offsetX || 0; + y -= offsetY || 0; + width = 0; + height = 0; + + if (!isAutoUpdateEvent || canTrackCursorOnAutoUpdate) { + width = data.axis === "y" ? domRect.width : 0; + height = data.axis === "x" ? domRect.height : 0; + x = isXAxis && data.x != null ? data.x : x; + y = isYAxis && data.y != null ? data.y : y; + } else if (isAutoUpdateEvent && !canTrackCursorOnAutoUpdate) { + height = data.axis === "x" ? domRect.height : height; + width = data.axis === "y" ? domRect.width : width; + } + + isAutoUpdateEvent = true; + + return { + width, + height, + x, + y, + top: y, + right: x + width, + bottom: y + height, + left: x, + }; + }, + }; +} + +function isMouseBasedEvent(event: Event | undefined): event is MouseEvent { + return event != null && (event as MouseEvent).clientX != null; +} + +interface UseClientPointOptions { + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: MaybeGetter; + /** + * Whether to restrict the client point to an axis and use the reference + * element (if it exists) as the other axis. This can be useful if the + * floating element is also interactive. + * @default 'both' + */ + axis?: MaybeGetter<"x" | "y" | "both">; + /** + * An explicitly defined `x` client coordinate. + * @default null + */ + x?: MaybeGetter; + /** + * An explicitly defined `y` client coordinate. + * @default null + */ + y?: MaybeGetter; +} + +function useClientPoint( + context: FloatingContextData, + 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. + const openCheck = $derived( + isMouseLikePointerType(pointerType) + ? context.elements.floating + : context.open, + ); + + 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 (context.data.openEvent && !isMouseBasedEvent(context.data.openEvent)) { + return; + } + + context.setPositionReference( + createVirtualElement(context.elements.domReference, { + x, + y, + axis: axis, + data: context.data, + pointerType: pointerType, + }), + ); + } + + function handleReferenceEnterOrMove(event: MouseEvent) { + if (x != null || y != null) return; + + 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). + listenerDeps = []; + } + } + + function addListener() { + if (!openCheck || !enabled || x != null || y != null) { + // Clear existing listener when conditions change + if (cleanupListener) { + cleanupListener(); + cleanupListener = null; + } + return; + } + + // Clear existing listener before adding new one + if (cleanupListener) { + cleanupListener(); + cleanupListener = null; + } + + const win = getWindow(context.elements.floating); + + const handleMouseMove = (event: MouseEvent) => { + const target = getTarget(event) as Element | null; + if (!contains(context.elements.floating, target)) { + setReference(event.clientX, event.clientY); + } else { + win.removeEventListener("mousemove", handleMouseMove); + cleanupListener = null; + } + }; + + if (!context.data.openEvent || isMouseBasedEvent(context.data.openEvent)) { + win.addEventListener("mousemove", handleMouseMove); + const cleanup = () => { + win.removeEventListener("mousemove", handleMouseMove); + cleanupListener = null; + }; + cleanupListener = cleanup; + return cleanup; + } + + context.setPositionReference(context.elements.domReference); + } + + function setPointerType(event: PointerEvent) { + pointerType = event.pointerType; + } + + $effect(() => { + listenerDeps; + return addListener(); + }); + + $effect(() => { + if (enabled && !context.elements.floating) { + initial = false; + } + }); + + $effect(() => { + if (!enabled && context.open) { + initial = true; + } + }); + + $effect.pre(() => { + if (enabled && (x != null || y != null)) { + initial = false; + setReference(x, y); + } + }); + + $effect(() => { + if (enabled && context.open) { + listenerDeps = []; + } + }); + + $effect(() => { + 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 }; 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..2fd56c79 100644 --- a/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-dismiss.svelte.ts @@ -4,6 +4,7 @@ import { isElement, isHTMLElement, isLastTraversableNode, + isWebKit, } from "@floating-ui/utils/dom"; import { contains, @@ -13,7 +14,14 @@ import { isEventTargetWithin, isRootElement, } from "../internal/dom.js"; -import type { FloatingContext } from "./use-floating.svelte.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"; +import { extract } from "../internal/extract.js"; +import type { ElementProps } from "./use-interactions.svelte.js"; +import { FLOATING_ID_ATTRIBUTE } from "../internal/attributes.js"; +import type { FloatingContextData } from "./use-floating-context.svelte.js"; const bubbleHandlerKeys = { pointerdown: "onpointerdown", @@ -27,9 +35,9 @@ const captureHandlerKeys = { click: "onclickcapture", }; -const normalizeProp = ( +function normalizeProp( normalizable?: boolean | { escapeKey?: boolean; outsidePress?: boolean }, -) => { +) { return { escapeKey: typeof normalizable === "boolean" @@ -40,7 +48,7 @@ const normalizeProp = ( ? normalizable : (normalizable?.outsidePress ?? true), }; -}; +} interface UseDismissOptions { /** @@ -48,19 +56,19 @@ interface UseDismissOptions { * handlers. * @default true */ - enabled?: boolean; + enabled?: MaybeGetter; /** * Whether to dismiss the floating element upon pressing the `esc` key. * @default true */ - escapeKey?: boolean; + escapeKey?: MaybeGetter; /** * Whether to dismiss the floating element upon pressing the reference * element. You likely want to ensure the `move` option in the `useHover()` * Hook has been disabled when this is in use. * @default false */ - referencePress?: boolean; + referencePress?: MaybeGetter; /** * The type of event to use to determine a “press”. * - `pointerdown` is eager on both mouse + touch input. @@ -68,7 +76,7 @@ interface UseDismissOptions { * - `click` is lazy on both mouse + touch input. * @default 'pointerdown' */ - referencePressEvent?: "pointerdown" | "mousedown" | "click"; + referencePressEvent?: MaybeGetter<"pointerdown" | "mousedown" | "click">; /** * Whether to dismiss the floating element upon pressing outside of the * floating element. @@ -90,120 +98,115 @@ interface UseDismissOptions { * - `click` is lazy on both mouse + touch input. * @default 'pointerdown' */ - outsidePressEvent?: "pointerdown" | "mousedown" | "click"; + outsidePressEvent?: MaybeGetter<"pointerdown" | "mousedown" | "click">; /** * Whether to dismiss the floating element upon scrolling an overflow * ancestor. * @default false */ - ancestorScroll?: boolean; + ancestorScroll?: MaybeGetter; /** * Determines whether event listeners bubble upwards through a tree of * floating elements. */ - bubbles?: boolean | { escapeKey?: boolean; outsidePress?: boolean }; + bubbles?: MaybeGetter< + boolean | { escapeKey?: boolean; outsidePress?: boolean } + >; /** * Determines whether to use capture phase event listeners. */ - capture?: boolean | { escapeKey?: boolean; outsidePress?: boolean }; + capture?: MaybeGetter< + boolean | { escapeKey?: boolean; outsidePress?: boolean } + >; } -function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { - const { - open, - onOpenChange, - // nodeId, - elements: { reference, floating }, - data, - } = $derived(context); - - const { - enabled = true, - escapeKey = true, - outsidePress: unstable_outsidePress = true, - outsidePressEvent = "pointerdown", - referencePress = false, - referencePressEvent = "pointerdown", - ancestorScroll = false, - bubbles, - capture, - } = $derived(options); - - // const tree = useFloatingTree(); +function useDismiss( + context: FloatingContextData, + opts: UseDismissOptions = {}, +): ElementProps { + const enabled = $derived(extract(opts.enabled, true)); + const escapeKey = $derived(extract(opts.escapeKey, true)); + const outsidePressProp = $derived(opts.outsidePress ?? true); + const outsidePressEvent = $derived( + extract(opts.outsidePressEvent, "pointerdown"), + ); + const referencePress = $derived(extract(opts.referencePress, false)); + const referencePressEvent = $derived( + extract(opts.referencePressEvent, "pointerdown"), + ); + 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, ); - let insideReactTree = false; + const bubbleOptions = $derived(normalizeProp(bubbles)); + const captureOptions = $derived(normalizeProp(capture)); + let endedOrStartedInside = false; - const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = - normalizeProp(bubbles); - const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = - normalizeProp(capture); + let isComposing = false; + let insideTree = false; - const closeOnEscapeKeyDown = (event: KeyboardEvent) => { - if (!open || !enabled || !escapeKey || event.key !== "Escape") { + function closeOnEscapeKeyDown(event: KeyboardEvent) { + if (!context.open || !enabled || !escapeKey || event.key !== "Escape") return; - } - // const children = tree ? getChildren(tree.nodesRef.current, nodeId) : []; + // Wait until IME is settled. Pressing `Escape` while composing should + // close the compose menu, but not the floating element. + if (isComposing) return; - if (!escapeKeyBubbles) { + const nodeId = context.data.floatingContext?.nodeId; + const children = tree ? getChildren(tree?.nodes, nodeId) : []; + + if (!bubbleOptions.escapeKey) { event.stopPropagation(); - // if (children.length > 0) { - // let shouldDismiss = true; + if (children.length > 0) { + let shouldDismiss = true; - // children.forEach((child) => { - // if (child.context?.open && !child.context.dataRef.current.__escapeKeyBubbles) { - // shouldDismiss = false; - // return; - // } - // }); + for (const child of children) { + if (child.context?.open && !child.context.data.__escapeKeyBubbles) { + shouldDismiss = false; + break; + } + } - // if (!shouldDismiss) { - // return; - // } - // } + if (!shouldDismiss) return; + } } - onOpenChange(false, event, "escape-key"); - }; + context.onOpenChange(false, event, "escape-key"); + } - const closeOnEscapeKeyDownCapture = (event: KeyboardEvent) => { + function closeOnEscapeKeyDownCapture(event: KeyboardEvent) { const callback = () => { closeOnEscapeKeyDown(event); getTarget(event)?.removeEventListener("keydown", callback); }; getTarget(event)?.addEventListener("keydown", callback); - }; + } - const closeOnPressOutside = (event: MouseEvent) => { - // Given developers can stop the propagation of the synthetic event, - // we can only be confident with a positive value. - const insideReactTreeLocal = insideReactTree; - insideReactTree = 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 endedOrStartedInsideLocal = endedOrStartedInside; + const localEndedOrStartedInside = endedOrStartedInside; endedOrStartedInside = false; - if (outsidePressEvent === "click" && endedOrStartedInsideLocal) { + if (outsidePressEvent === "click" && localEndedOrStartedInside) { return; } - if (insideReactTreeLocal) { - return; - } + if (localInsideTree) return; if (typeof outsidePress === "function" && !outsidePress(event)) { return; @@ -211,14 +214,15 @@ 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)) { const nextParent = getParentNode(targetRootAncestor); - if (isLastTraversableNode(nextParent) || !isElement(nextParent)) { - break; - } + if (isLastTraversableNode(nextParent) || !isElement(nextParent)) break; targetRootAncestor = nextParent; } @@ -230,7 +234,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,115 +245,180 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { } // Check if the click occurred on the scrollbar - if (isHTMLElement(target) && floating) { - // In Firefox, `target.scrollWidth > target.clientWidth` for inline - // elements. + if (isHTMLElement(target) && context.elements.floating) { + const lastTraversableNode = isLastTraversableNode(target); + const style = getComputedStyle(target); + const scrollRe = /auto|scroll/; + const isScrollableX = + lastTraversableNode || scrollRe.test(style.overflowX); + const isScrollableY = + lastTraversableNode || scrollRe.test(style.overflowY); + const canScrollX = - target.clientWidth > 0 && target.scrollWidth > target.clientWidth; + isScrollableX && + target.clientWidth > 0 && + target.scrollWidth > target.clientWidth; const canScrollY = - target.clientHeight > 0 && target.scrollHeight > target.clientHeight; + isScrollableY && + target.clientHeight > 0 && + target.scrollHeight > target.clientHeight; - let xCond = canScrollY && event.offsetX > target.clientWidth; + const isRTL = style.direction === "rtl"; + // Check click position relative to scrollbar. // In some browsers it is possible to change the (or window) // scrollbar to the left side, but is very rare and is difficult to // check for. Plus, for modal dialogs with backdrops, it is more // important that the backdrop is checked but not so much the window. - if (canScrollY) { - const isRTL = getComputedStyle(target).direction === "rtl"; + const pressedVerticalScrollbar = + canScrollY && + (isRTL + ? event.offsetX <= target.offsetWidth - target.clientWidth + : event.offsetX > target.clientWidth); - if (isRTL) { - xCond = event.offsetX <= target.offsetWidth - target.clientWidth; - } - } + const pressedHorizontalScrollbar = + canScrollX && event.offsetY > target.clientHeight; - if (xCond || (canScrollX && event.offsetY > target.clientHeight)) { + if (pressedVerticalScrollbar || pressedHorizontalScrollbar) { return; } } - // const targetIsInsideChildren = - // tree && - // getChildren(tree.nodesRef.current, nodeId).some((node) => - // isEventTargetWithin(event, node.context?.elements.floating), - // ); + const nodeId = context.data.floatingContext?.nodeId; + + const targetIsInsideChildren = + tree && + getChildren(tree?.nodes, nodeId).some((node) => + isEventTargetWithin(event, node.context?.elements.floating), + ); if ( - isEventTargetWithin(event, floating) || - // @ts-expect-error - FIXME - isEventTargetWithin(event, reference) - // targetIsInsideChildren + isEventTargetWithin(event, context.elements.floating) || + isEventTargetWithin(event, context.elements.domReference) || + targetIsInsideChildren ) { return; } - // const children = tree ? getChildren(tree.nodesRef.current, nodeId) : []; - // if (children.length > 0) { - // let shouldDismiss = true; + const children = tree ? getChildren(tree?.nodes, nodeId) : []; - // children.forEach((child) => { - // if (child.context?.open && !child.context.dataRef.current.__outsidePressBubbles) { - // shouldDismiss = false; - // return; - // } - // }); + if (children.length > 0) { + let shouldDismiss = true; - // if (!shouldDismiss) { - // return; - // } - // } + for (const child of children) { + if (child.context?.open && !child.context.data.__outsidePressBubbles) { + shouldDismiss = false; + break; + } + } - onOpenChange(false, event, "outside-press"); - }; + 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"); + } - const closeOnPressOutsideCapture = (event: MouseEvent) => { + function closeOnPressOutsideCapture(event: MouseEvent) { const callback = () => { closeOnPressOutside(event); getTarget(event)?.removeEventListener(outsidePressEvent, callback); }; getTarget(event)?.addEventListener(outsidePressEvent, callback); - }; + } + + function onScroll(event: Event) { + context.onOpenChange(false, event, "ancestor-scroll"); + } + + let compositionTimeout = -1; + + function 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, + ); + } - $effect(() => { - if (!open || !enabled) { - return; - } + $effect.pre(() => { + if (!context.open || !enabled) return; + context.data.__escapeKeyBubbles = bubbleOptions.escapeKey; + context.data.__outsidePressBubbles = bubbleOptions.outsidePress; - data.__escapeKeyBubbles = escapeKeyBubbles; - data.__outsidePressBubbles = outsidePressBubbles; + const doc = getDocument(context.elements.floating); + const listenersToRemove: Array<() => void> = []; - function onScroll(event: Event) { - onOpenChange(false, event, "ancestor-scroll"); + if (escapeKey) { + listenersToRemove.push( + on( + doc, + "keydown", + captureOptions.escapeKey + ? closeOnEscapeKeyDownCapture + : closeOnEscapeKeyDown, + { capture: captureOptions.escapeKey }, + ), + on(doc, "compositionstart", handleCompositionStart), + on(doc, "compositionend", handleCompositionEnd), + ); } - const doc = getDocument(floating); - escapeKey && - doc.addEventListener( - "keydown", - escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown, - escapeKeyCapture, - ); - outsidePress && - doc.addEventListener( - outsidePressEvent, - outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside, - outsidePressCapture, + if (outsidePress) { + listenersToRemove.push( + on( + doc, + outsidePressEvent, + captureOptions.outsidePress + ? closeOnPressOutsideCapture + : closeOnPressOutside, + { capture: captureOptions.outsidePress }, + ), ); + } let ancestors: (Element | Window | VisualViewport)[] = []; if (ancestorScroll) { - if (isElement(reference)) { - ancestors = getOverflowAncestors(reference); + if (isElement(context.elements.domReference)) { + ancestors = getOverflowAncestors(context.elements.domReference); } - 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), ); } } @@ -360,69 +429,63 @@ function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) { ); for (const ancestor of ancestors) { - ancestor.addEventListener("scroll", onScroll, { passive: true }); + listenersToRemove.push( + on(ancestor, "scroll", onScroll, { passive: true }), + ); } return () => { - escapeKey && - doc.removeEventListener( - "keydown", - escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown, - escapeKeyCapture, - ); - outsidePress && - doc.removeEventListener( - outsidePressEvent, - outsidePressCapture - ? closeOnPressOutsideCapture - : closeOnPressOutside, - outsidePressCapture, - ); - for (const ancestor of ancestors) { - ancestor.removeEventListener("scroll", onScroll); + for (const removeListener of listenersToRemove) { + removeListener(); } + window.clearTimeout(compositionTimeout); }; }); - $effect(() => { + $effect.pre(() => { [outsidePress, outsidePressEvent]; - insideReactTree = false; + insideTree = false; }); - return { - get reference() { - if (!enabled) { - return {}; - } - return { - onKeyDown: closeOnEscapeKeyDown, - [bubbleHandlerKeys[referencePressEvent]]: (event: Event) => { - if (referencePress) { - onOpenChange(false, event, "reference-press"); - } + 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"); }, - }; + }), + }), + }); + + const floating = $derived({ + onkeydown: closeOnEscapeKeyDown, + onmousedown: () => { + endedOrStartedInside = true; + }, + onmouseup: () => { + endedOrStartedInside = true; + }, + [captureHandlerKeys[outsidePressEvent]]: () => { + insideTree = true; }, + [FLOATING_ID_ATTRIBUTE]: context.floatingId, + }); + return { + get reference() { + if (!enabled) return {}; + return reference; + }, get floating() { - if (!enabled) { - return {}; - } - return { - onKeyDown: closeOnEscapeKeyDown, - onMouseDown() { - endedOrStartedInside = true; - }, - onMouseUp() { - endedOrStartedInside = true; - }, - [captureHandlerKeys[outsidePressEvent]]: () => { - insideReactTree = true; - }, - }; + if (!enabled) return {}; + return floating; }, }; } export type { UseDismissOptions }; -export { useDismiss }; +export { useDismiss, normalizeProp }; diff --git a/packages/floating-ui-svelte/src/hooks/use-floating-context.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating-context.svelte.ts new file mode 100644 index 00000000..3bc491e2 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-floating-context.svelte.ts @@ -0,0 +1,108 @@ +import type { + ContextData, + FloatingEvents, + OnOpenChange, + ReferenceType, +} from "../types.js"; +import type { FloatingRootContext } from "./use-floating-root-context.svelte.js"; +import type { PositionState } from "./use-position.svelte.js"; +import type { Placement, Strategy } from "@floating-ui/utils"; +import type { MiddlewareData } from "@floating-ui/dom"; +import type { FloatingOptions } from "./use-floating-options.svelte.js"; +import type { FloatingState } from "./use-floating.svelte.js"; + +export interface FloatingContextOptions< + RT extends ReferenceType = ReferenceType, +> { + floatingState: Omit, "context">; + floatingOptions: FloatingOptions; + rootContext: FloatingRootContext; + positionState: PositionState; +} + +export interface FloatingContextData { + elements: { + reference: ReferenceType | null; + floating: HTMLElement | null; + domReference: HTMLElement | null; + }; + x: number; + y: number; + placement: Placement; + strategy: Strategy; + middlewareData: MiddlewareData; + isPositioned: boolean; + update: () => Promise; + floatingStyles: string; + onOpenChange: OnOpenChange; + open: boolean; + data: ContextData; + floatingId: string; + events: FloatingEvents; + nodeId: string | undefined; + setPositionReference: (node: ReferenceType | null) => void; + "~position": PositionState; +} + +export function useFloatingContext( + opts: FloatingContextOptions, +): FloatingContextData { + const elements = $state({ + get reference() { + return opts.floatingOptions.reference.current as ReferenceType | null; + }, + get floating() { + return opts.floatingOptions.floating.current; + }, + set floating(node: HTMLElement | null) { + opts.floatingOptions.floating.current = node; + }, + get domReference() { + return opts.floatingState.elements.domReference; + }, + }); + + return { + elements, + get x() { + return opts.floatingState.x; + }, + get y() { + return opts.floatingState.y; + }, + get placement() { + return opts.floatingState.placement; + }, + get strategy() { + return opts.floatingState.strategy; + }, + get middlewareData() { + return opts.floatingState.middlewareData; + }, + get isPositioned() { + return opts.floatingState.isPositioned; + }, + get floatingStyles() { + return opts.floatingState.floatingStyles; + }, + get open() { + return opts.rootContext.open; + }, + get data() { + return opts.rootContext.data; + }, + get floatingId() { + return opts.rootContext.floatingId; + }, + get events() { + return opts.rootContext.events; + }, + get nodeId() { + return opts.floatingOptions.nodeId.current; + }, + onOpenChange: opts.rootContext.onOpenChange, + update: opts.floatingState.update, + setPositionReference: opts.floatingState.setPositionReference, + "~position": opts.positionState, + }; +} diff --git a/packages/floating-ui-svelte/src/hooks/use-floating-options.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-floating-options.svelte.ts new file mode 100644 index 00000000..0a547171 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-floating-options.svelte.ts @@ -0,0 +1,163 @@ +import type { + MaybeGetter, + OpenChangeReason, + ReferenceType, + WhileElementsMounted, +} from "../types.js"; +import type { FloatingRootContext } from "./use-floating-root-context.svelte.js"; +import type { Placement, Strategy } from "@floating-ui/utils"; +import type { Middleware } from "@floating-ui/dom"; +import { box } from "../internal/box.svelte.js"; +import { extract } from "../internal/extract.js"; +import { noop } from "../internal/noop.js"; +import { useId } from "./use-id.js"; + +export interface UseFloatingOptions { + /** + * Represents the open/close state of the floating element. + * @default true + */ + open?: MaybeGetter; + + /** + * Where to place the floating element relative to its reference element. + * @default 'bottom' + */ + placement?: MaybeGetter; + + /** + * The type of CSS position property to use. + * @default 'absolute' + */ + strategy?: MaybeGetter; + + /** + * These are plain objects that modify the positioning coordinates in some fashion, or provide useful data for the consumer to use. + * @default [] + */ + middleware?: MaybeGetter>; + + /** + * Whether to use `transform` instead of `top` and `left` styles to + * position the floating element (`floatingStyles`). + * @default true + */ + transform?: MaybeGetter; + + /** + * Callback to handle mounting/unmounting of the elements. + * @default undefined + */ + whileElementsMounted?: WhileElementsMounted; + + rootContext?: MaybeGetter>; + elements?: { + /** + * The reference element. + */ + reference?: MaybeGetter; + /** + * The floating element. + */ + floating?: MaybeGetter; + }; + + /** + * A callback that is invoked when the reference element changes. + */ + onReferenceChange?(node: Element | null): void; + + /** + * A callback that is invoked when the floating element changes. + */ + onFloatingChange?(node: HTMLElement | null): void; + + /** + * 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?: MaybeGetter; + + /** + * A unique id for the floating element. + * + * @default useId() + */ + floatingId?: MaybeGetter; +} + +export interface FloatingOptions + extends ReturnType> {} + +/** + * Returns reactive options state to use with the various internal hooks. + */ +export function useFloatingOptions( + options: UseFloatingOptions, +) { + const floatingId = box.with(() => extract(options.floatingId) ?? useId()); + const floatingProp = $derived(extract(options.elements?.floating, null)); + const referenceProp = $derived(extract(options.elements?.reference, null)); + const open = box.with(() => extract(options.open, true)); + const placement = box.with(() => extract(options.placement, "bottom")); + const strategy = box.with(() => extract(options.strategy, "absolute")); + const middleware = box.with(() => extract(options.middleware, [])); + const transform = box.with(() => extract(options.transform, true)); + const onOpenChange = options.onOpenChange ?? noop; + const onReferenceChange = options.onReferenceChange ?? noop; + const onFloatingChange = options.onFloatingChange ?? noop; + const whileElementsMounted = options.whileElementsMounted; + const nodeId = box.with(() => extract(options.nodeId)); + const rootContext = box.with( + () => extract(options.rootContext) as FloatingRootContext | undefined, + ); + let _stableReference = $state(null); + let _stableFloating = $state(null); + const reference = box.with( + () => _stableReference, + (node) => { + _stableReference = node; + onReferenceChange(node); + }, + ); + const floating = box.with( + () => _stableFloating, + (node) => { + _stableFloating = node; + onFloatingChange(node); + }, + ); + reference.current = extract(options.elements?.reference, null); + floating.current = extract(options.elements?.floating, null); + + $effect.pre(() => { + if (!floatingProp) return; + floating.current = floatingProp; + }); + + $effect.pre(() => { + if (!referenceProp) return; + reference.current = referenceProp; + }); + + return { + open, + placement, + strategy, + middleware, + transform, + whileElementsMounted, + rootContext, + floatingId, + onReferenceChange, + onFloatingChange, + onOpenChange, + nodeId, + reference, + 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 new file mode 100644 index 00000000..d8118fa7 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-floating-root-context.svelte.ts @@ -0,0 +1,154 @@ +import type { ReferenceElement } from "@floating-ui/dom"; +import type { + ContextData, + MaybeGetter, + OnOpenChange, + OpenChangeReason, + ReferenceType, +} 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"; +import { box } from "../internal/box.svelte.js"; +import { extract } from "../internal/extract.js"; +import { noop } from "../internal/noop.js"; + +interface UseFloatingRootContextOptions { + open?: MaybeGetter; + onOpenChange?: OnOpenChange; + reference: MaybeGetter; + floating: MaybeGetter; + onReferenceChange?: (node: Element | null) => void; + onFloatingChange?: (node: HTMLElement | null) => void; + /** + * The id to assign to the floating element. + * + * @default useId() + */ + floatingId?: MaybeGetter; +} + +interface FloatingRootContextOptions + extends ReturnType {} + +function useFloatingRootContextOptions(opts: UseFloatingRootContextOptions) { + const open = box.with(() => extract(opts.open, false)); + const floatingId = $derived(extract(opts.floatingId, useId()) ?? useId()); + let _stableReference = $state(null); + let _stableFloating = $state(null); + const reference = box.with( + () => _stableReference, + (node) => { + _stableReference = node; + opts.onReferenceChange?.(node as Element | null); + }, + ); + const floating = box.with( + () => _stableFloating, + (node) => { + _stableFloating = node; + opts.onFloatingChange?.(node); + }, + ); + + reference.current = extract(opts.reference, null); + floating.current = extract(opts.floating, null); + + $effect.pre(() => { + reference.current = extract(opts.reference, null); + }); + + $effect.pre(() => { + floating.current = extract(opts.floating, null); + }); + + return { + open, + onOpenChange: opts.onOpenChange ?? noop, + onReferenceChange: opts.onReferenceChange ?? noop, + onFloatingChange: opts.onFloatingChange ?? noop, + reference, + floating, + get floatingId() { + return floatingId; + }, + }; +} + +export interface FloatingRootContext + extends ReturnType> {} + +export function useFloatingRootContext< + RT extends ReferenceType = ReferenceType, +>(_opts: UseFloatingRootContextOptions) { + const opts = useFloatingRootContextOptions(_opts); + + if (DEV) { + if (opts.reference.current && !isElement(opts.reference.current)) { + error( + "Cannot pass a virtual element to the `elements.reference` option,", + "as it must be a real DOM element. Use `floating.setPositionReference()`", + "instead.", + ); + } + } + const data: ContextData = $state({}); + const events = createPubSub(); + /** Whether the floating element is nested inside another floating element. */ + const nested = useFloatingParentNodeId() != null; + /** Enables the user to specify a position reference after initialization. */ + let positionReference = $state( + opts.reference.current, + ); + const reference = $derived( + (positionReference || opts.reference.current || null) as RT | null, + ); + + function onOpenChange( + open: boolean, + event?: Event, + reason?: OpenChangeReason, + ) { + data.openEvent = open ? event : undefined; + events.emit("openchange", { + open, + event, + reason, + nested, + }); + opts.onOpenChange?.(open, event, reason); + } + + function setPositionReference(node: ReferenceElement | null) { + positionReference = node; + } + + const elements = $state({ + get reference() { + return reference; + }, + get floating() { + return opts.floating.current; + }, + get domReference() { + return opts.reference.current; + }, + }); + + return { + get floatingId() { + return opts.floatingId; + }, + data, + events, + get open() { + return opts.open.current; + }, + elements, + onOpenChange, + setPositionReference, + }; +} 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 dfe3f1e0..a8db4116 100644 --- a/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-floating.svelte.ts @@ -1,401 +1,139 @@ +import { useFloatingTree } from "../components/floating-tree/hooks.svelte.js"; +import type { NarrowedElement, ReferenceType } from "../types.js"; import { - type ComputePositionConfig, - type FloatingElement, - type Middleware, - type MiddlewareData, - type Placement, - type ReferenceElement, - 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"; - -interface FloatingElements { - /** - * The reference element. - */ - reference?: ReferenceElement | null; - - /** - * The floating element. - */ - floating?: FloatingElement | null; -} - -interface UseFloatingOptions { - /** - * 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' - */ - placement?: Placement; - - /** - * The type of CSS position property to use. - * @default 'absolute' - */ - strategy?: Strategy; - - /** - * These are plain objects that modify the positioning coordinates in some fashion, or provide useful data for the consumer to use. - * @default [] - */ - middleware?: Array; - - /** - * Whether to use `transform` instead of `top` and `left` styles to - * position the floating element (`floatingStyles`). - * @default true - */ - transform?: boolean; - - /** - * Object containing the floating and reference elements. - * @default {} - */ - elements?: FloatingElements; - - /** - * Callback to handle mounting/unmounting of the elements. - * @default undefined - */ - whileElementsMounted?: ( - reference: ReferenceElement, - floating: FloatingElement, - update: () => void, - ) => () => void; - - /** - * Unique node id when using `FloatingTree`. - * @default undefined - */ - nodeId?: string; -} - -interface UseFloatingData { - /** - * The x-coordinate of the floating element. - */ - x: number; - - /** - * The y-coordinate of the floating element. - */ - y: number; - - /** - * The stateful placement, which can be different from the initial `placement` passed as options. - */ + type FloatingRootContext, + useFloatingRootContext, +} from "./use-floating-root-context.svelte.js"; +import { isElement } from "@floating-ui/utils/dom"; +import { + useFloatingOptions, + type UseFloatingOptions, +} from "./use-floating-options.svelte.js"; +import { usePosition } from "./use-position.svelte.js"; +import { + useFloatingContext, + type FloatingContextData, +} from "./use-floating-context.svelte.js"; +import type { Placement } from "@floating-ui/utils"; +import type { Strategy } from "@floating-ui/utils"; +import type { MiddlewareData } from "@floating-ui/dom"; + +export interface FloatingState { + elements: { + domReference: HTMLElement | null; + reference: Element | null; + floating: HTMLElement | null; + }; placement: Placement; - - /** - * The stateful strategy, which can be different from the initial `strategy` passed as options. - */ strategy: Strategy; - - /** - * Additional data from middleware. - */ middlewareData: MiddlewareData; - - /** - * The boolean that let you know if the floating element has been positioned. - */ 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; - - /** - * 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. - */ - readonly open: boolean; - - /** - * 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. - */ - readonly context: FloatingContext; + x: number; + y: number; + floatingStyles: string; + update: () => Promise; + setPositionReference: (node: ReferenceType | null) => void; + context: FloatingContextData; } /** - * Hook for managing floating elements. + * Provides data to position a floating element and context to add interactions. */ -function useFloating(options: UseFloatingOptions = {}): UseFloatingReturn { - const elements = $state(options.elements ?? {}); - const { - placement = "bottom", - strategy = "absolute", - middleware = [], - transform = true, - open = true, - onOpenChange: unstableOnOpenChange = noop, - whileElementsMounted, - nodeId, - } = $derived(options); - const floatingStyles = $derived.by(() => { - const initialStyles = { - position: strategy, - left: "0px", - top: "0px", - }; - - if (!elements.floating) { - return styleObjectToString(initialStyles); - } - - const x = roundByDPR(elements.floating, state.x); - const y = roundByDPR(elements.floating, state.y); - - if (transform) { - return styleObjectToString({ - ...initialStyles, - transform: `translate(${x}px, ${y}px)`, - ...(getDPR(elements.floating) >= 1.5 && { willChange: "transform" }), - }); - } - - return styleObjectToString({ - position: strategy, - left: `${x}px`, - top: `${y}px`, - }); +export function useFloating( + _opts: UseFloatingOptions = {}, +) { + const opts = useFloatingOptions(_opts); + const internalRootContext = useFloatingRootContext({ + open: () => opts.open.current ?? true, + reference: () => opts.reference.current, + floating: () => opts.floating.current, + onOpenChange: opts.onOpenChange, + floatingId: () => opts.floatingId.current, }); - - 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({ - x: 0, - y: 0, - strategy, - placement, - middlewareData: {}, - 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; + const rootContext = (opts.rootContext.current ?? + internalRootContext) as unknown as FloatingRootContext; + const tree = useFloatingTree(); + let positionReference = $state(null); + const position = usePosition( + opts, + rootContext, + () => positionReference as RT | null, + ); + const derivedDomReference = $derived( + (rootContext.elements.domReference || + opts.reference.current) as NarrowedElement, + ); + + const floatingState = { + elements: { + get domReference() { + return derivedDomReference as HTMLElement | null; + }, + get reference() { + return position.referenceEl as Element | null; + }, + set reference(node: Element | null) { + if (isElement(node) || node === null) { + opts.reference.current = node; + } + }, + get floating() { + return opts.floating.current; + }, + set floating(node: HTMLElement | null) { + opts.floating.current = node; + }, }, get placement() { - return state.placement; + return position.data.placement; }, get strategy() { - return state.strategy; + return position.data.strategy; }, get middlewareData() { - return state.middlewareData; + return position.data.middlewareData; }, get isPositioned() { - return state.isPositioned; + return position.data.isPositioned; }, - get open() { - return open; + get x() { + return position.data.x; + }, + get y() { + return position.data.y; + }, + get floatingStyles() { + return position.floatingStyles; + }, + update: position.update, + setPositionReference: (node: ReferenceType | null) => { + const computedPositionReference = isElement(node) + ? { + getBoundingClientRect: () => node.getBoundingClientRect(), + contextElement: node, + } + : node; + positionReference = computedPositionReference; }, - }); - - const update = async () => { - if (!elements.floating || !elements.reference) { - return; - } - - const config: ComputePositionConfig = { - placement, - strategy, - middleware, - }; - - const position = await computePosition( - elements.reference, - elements.floating, - config, - ); - - state.x = position.x; - state.y = position.y; - state.placement = position.placement; - state.strategy = position.strategy; - state.middlewareData = position.middlewareData; - state.isPositioned = true; }; - $effect.pre(() => { - if (!options.elements || !options.elements.reference) { - return; - } - elements.reference = options.elements.reference; - }); - - $effect.pre(() => { - if (!options.elements || !options.elements.floating) { - return; - } - elements.floating = options.elements.floating; + const context = useFloatingContext({ + floatingState: floatingState, + floatingOptions: opts, + rootContext, + positionState: position, }); - $effect.pre(() => { - if (open || !state.isPositioned) { - return; - } - - state.isPositioned = false; - }); + Object.assign(floatingState, { context }); $effect.pre(() => { - if (!elements.floating || !elements.reference) { - return; - } + rootContext.data.floatingContext = context; - if (!whileElementsMounted) { - update(); - return; + const node = tree?.nodes.find((node) => node.id === opts.nodeId.current); + if (node) { + node.context = context; } - - return whileElementsMounted(elements.reference, elements.floating, update); }); - 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; - }, - get floatingStyles() { - return floatingStyles; - }, - }; + return floatingState as FloatingState; } - -export type { UseFloatingOptions, UseFloatingReturn, FloatingContext }; -export { useFloating }; 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..29286dc5 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,12 @@ 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 { MaybeGetter, OpenChangeReason } from "../types.js"; +import { on } from "svelte/events"; +import { executeCallbacks } from "../internal/execute-callbacks.js"; +import { extract } from "../internal/extract.js"; +import type { FloatingContextData } from "./use-floating-context.svelte.js"; interface UseFocusOptions { /** @@ -18,162 +21,153 @@ interface UseFocusOptions { * handlers. * @default true */ - enabled?: boolean; + enabled?: MaybeGetter; /** * Whether the open state only changes if the focus event is considered * visible (`:focus-visible` CSS selector). * @default true */ - visibleOnly?: boolean; + visibleOnly?: MaybeGetter; } -function useFocus(context: FloatingContext, options: UseFocusOptions = {}) { - const { - open, - onOpenChange, - events, - elements: { reference, floating }, - } = $derived(context); - - const { enabled = true, visibleOnly = true } = $derived(options); - +function useFocus(context: FloatingContextData, 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; - $effect(() => { - if (!enabled) { + function onpointerdown(event: PointerEvent) { + if (isVirtualPointerEvent(event)) return; + keyboardModality = false; + } + + function onmouseleave() { + blockFocus = false; + } + + function onfocus(event: FocusEvent) { + if (blockFocus) { return; } - const win = getWindow(reference); + const target = getTarget(event); + + if (visibleOnly && isElement(target)) { + try { + // Mac Safari unreliably matches `:focus-visible` on the reference + // if focus was outside the page initially - use the fallback + // instead. + if (isSafari() && isMac()) throw Error(); + if (!target.matches(":focus-visible")) return; + } catch { + // Old browsers will throw an error when using `:focus-visible`. + if (!keyboardModality && !isTypeableElement(target)) { + return; + } + } + } - // 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 - // return to the tab/window. - function onBlur() { + context.onOpenChange(true, event, "focus"); + } + + function onblur(event: FocusEvent) { + blockFocus = false; + const relatedTarget = event.relatedTarget; + + // Hit the non-modal focus management portal guard. Focus will be + // moved into the floating element immediately after. + const movedToFocusGuard = + isElement(relatedTarget) && + relatedTarget.hasAttribute(createAttribute("focus-guard")) && + relatedTarget.getAttribute("data-type") === "outside"; + + // Wait for the window blur listener to fire. + timeout = window.setTimeout(() => { + const activeEl = activeElement( + isElement(context.elements.domReference) + ? context.elements.domReference.ownerDocument + : document, + ); + + // Focus left the page, keep it open. + if (!relatedTarget && activeEl === context.elements.domReference) return; + + // When focusing the reference element (e.g. regular click), then + // clicking into the floating element, prevent it from hiding. + // Note: it must be focusable, e.g. `tabindex="-1"`. + // We can not rely on relatedTarget to point to the correct element + // as it will only point to the shadow host of the newly focused element + // and not the element that actually has received focus if it is located + // inside a shadow root. if ( - !open && - isHTMLElement(reference) && - reference === activeElement(getDocument(reference)) + contains(context.elements.floating, activeEl) || + contains(context.elements.domReference, activeEl) || + movedToFocusGuard ) { - blockFocus = true; + return; } - } - function onKeyDown() { - keyboardModality = true; + context.onOpenChange(false, event, "focus"); + }); + } + + // 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. + function handleBlur() { + if ( + !context.open && + isHTMLElement(context.elements.domReference) && + context.elements.domReference === + activeElement(getDocument(context.elements.domReference)) + ) { + blockFocus = true; } + } - win.addEventListener("blur", onBlur); - win.addEventListener("keydown", onKeyDown, true); - return () => { - win.removeEventListener("blur", onBlur); - win.removeEventListener("keydown", onKeyDown, true); - }; - }); + function handleKeyDown() { + keyboardModality = true; + } $effect(() => { - if (!enabled) { - return; - } + if (!enabled) return; + const win = getWindow(context.elements.domReference); - function onOpenChange({ reason }: { reason: OpenChangeReason }) { - if (reason === "reference-press" || reason === "escape-key") { - blockFocus = true; - } + return executeCallbacks( + on(win, "blur", handleBlur), + on(win, "keydown", handleKeyDown, { capture: true }), + ); + }); + + function onOpenChange({ reason }: { reason: OpenChangeReason }) { + if (reason === "reference-press" || reason === "escape-key") { + blockFocus = true; } + } - events.on("openchange", onOpenChange); - return () => { - events.off("openchange", onOpenChange); - }; + $effect(() => { + if (!enabled) return; + return context.events.on("openchange", onOpenChange); }); $effect(() => { return () => { - clearTimeout(timeout); + window.clearTimeout(timeout); }; }); + const reference = $derived({ + onpointerdown: onpointerdown, + onmouseleave: onmouseleave, + onfocus: onfocus, + onblur: onblur, + }); + return { get reference() { - if (!enabled) { - return {}; - } - return { - onpointerdown: (event: PointerEvent) => { - if (isVirtualPointerEvent(event)) return; - keyboardModality = false; - }, - onmouseleave() { - blockFocus = false; - }, - onfocus: (event: FocusEvent) => { - if (blockFocus) { - return; - } - - const target = getTarget(event); - - if (visibleOnly && isElement(target)) { - try { - // Mac Safari unreliably matches `:focus-visible` on the reference - // if focus was outside the page initially - use the fallback - // instead. - if (isSafari() && isMac()) throw Error(); - if (!target.matches(":focus-visible")) return; - } catch { - // Old browsers will throw an error when using `:focus-visible`. - if (!keyboardModality && !isTypeableElement(target)) { - return; - } - } - } - - onOpenChange(true, event, "focus"); - }, - onblur: (event: FocusEvent) => { - blockFocus = false; - const relatedTarget = event.relatedTarget; - - // Hit the non-modal focus management portal guard. Focus will be - // moved into the floating element immediately after. - const movedToFocusGuard = - isElement(relatedTarget) && - relatedTarget.hasAttribute(createAttribute("focus-guard")) && - relatedTarget.getAttribute("data-type") === "outside"; - - // Wait for the window blur listener to fire. - timeout = window.setTimeout(() => { - const activeEl = activeElement( - // @ts-expect-error - FIXME - reference ? reference.ownerDocument : document, - ); - - // Focus left the page, keep it open. - if (!relatedTarget && activeEl === reference) return; - - // When focusing the reference element (e.g. regular click), then - // clicking into the floating element, prevent it from hiding. - // Note: it must be focusable, e.g. `tabindex="-1"`. - // We can not rely on relatedTarget to point to the correct element - // as it will only point to the shadow host of the newly focused element - // and not the element that actually has received focus if it is located - // inside a shadow root. - if ( - contains(floating, activeEl) || - // @ts-expect-error FIXME - contains(reference, activeEl) || - movedToFocusGuard - ) { - return; - } - - onOpenChange(false, event, "focus"); - }); - }, - }; + if (!enabled) return {}; + return 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 48171e03..bd7e1418 100644 --- a/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts @@ -4,11 +4,25 @@ import { createAttribute, getDocument, isMouseLikePointerType, + isPointerType, + type PointerType, } 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 { + FloatingTreeType, + MaybeGetter, + OpenChangeReason, +} from "../types.js"; +import { + useFloatingParentNodeId, + useFloatingTree, +} from "../components/floating-tree/hooks.svelte.js"; +import { snapshotFloatingContext } from "../internal/snapshot.svelte.js"; +import { extract } from "../internal/extract.js"; import type { ElementProps } from "./use-interactions.svelte.js"; +import { on } from "svelte/events"; +import { untrack } from "svelte"; +import type { FloatingContextData } from "./use-floating-context.svelte.js"; interface DelayOptions { /** @@ -26,8 +40,9 @@ interface DelayOptions { interface HandleCloseFn { ( - context: FloatingContext & { + context: FloatingContextData & { onClose: () => void; + tree?: FloatingTreeType | null; leave?: boolean; }, ): (event: MouseEvent) => void; @@ -41,31 +56,33 @@ interface UseHoverOptions { * Enables/disables the hook. * @default true */ - enabled?: boolean; + enabled?: MaybeGetter; /** * Only allow pointers of type mouse to trigger the hover (thus excluding pens and touchscreens). * @default false */ - mouseOnly?: boolean; + mouseOnly?: MaybeGetter; /** * Time in ms that will delay the change of the open state. * @default 0 */ - delay?: number | DelayOptions; + delay?: MaybeGetter; /** - * Time in ms that the pointer must rest on the reference element before the open state is set to true. + * Time in ms that the pointer must rest on the reference element before the open state + * is set to true. * @default 0 */ - restMs?: number; + restMs?: MaybeGetter; /** - * Whether moving the pointer over the floating element will open it, without a regular hover event required. + * Whether moving the pointer over the reference element will open its floating + * element without a regular hover event required. * @default true */ - move?: boolean; + move?: MaybeGetter; /** * Callback to handle the closing of the floating element. @@ -76,341 +93,364 @@ interface UseHoverOptions { const safePolygonIdentifier = createAttribute("safe-polygon"); +/** + * Returns the delay value for the given prop. + */ function getDelay( - value: UseHoverOptions["delay"], + value: number | DelayOptions, prop: "open" | "close", - pointerType?: PointerEvent["pointerType"], + pointerType?: PointerType, ) { - if (pointerType && !isMouseLikePointerType(pointerType)) { - return 0; - } - - if (typeof value === "number") { - return value; - } - + if (pointerType && !isMouseLikePointerType(pointerType)) return 0; + if (typeof value === "number") return value; return value?.[prop]; } -function useHover( - context: FloatingContext, - options: UseHoverOptions = {}, -): ElementProps { - const { - open, - onOpenChange, - data, - events, - elements: { reference, floating }, - } = $derived(context); - - const { - enabled = true, - mouseOnly = false, - delay = 0, - restMs = 0, - move = true, - handleClose = null, - } = $derived(options); - - // const tree = useFloatingTree(); - // const parentId = useFloatingParentNodeId(); - let pointerType: string | undefined = undefined; - let timeout = -1; - let handler: ((event: MouseEvent) => void) | undefined = undefined; - let restTimeout = -1; +function useHover(context: FloatingContextData, 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; + let openChangeTimeout = -1; + let restOpenChangeTimeout = -1; + let restTimeoutPending = false; + let handler: ((e: MouseEvent) => void) | undefined = noop; let blockMouseMove = true; - let performedPointerEventsMutation = false; - let unbindMouseMove = noop; + let mouseMoveListener = noop; + let clearPointerEvents = noop; + + function cleanupPointerEvents() { + clearPointerEvents(); + clearPointerEvents = noop; + } + + function removeMouseMoveListener() { + mouseMoveListener(); + mouseMoveListener = 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, ); - $effect(() => { - if (!enabled) { - return; - } - - const onOpenChange = ({ open }: { open: boolean }) => { - if (!open) { - clearTimeout(timeout); - clearTimeout(restTimeout); - blockMouseMove = true; - } - }; + function onOpenChange({ open }: { open: boolean }) { + if (open) return; + window.clearTimeout(openChangeTimeout); + window.clearTimeout(restOpenChangeTimeout); + blockMouseMove = true; + restTimeoutPending = false; + } - events.on("openchange", onOpenChange); - return () => { - events.off("openchange", onOpenChange); - }; + $effect(() => { + if (!enabled) return; + return context.events.on("openchange", onOpenChange); }); - $effect(() => { - if (enabled || !handleClose || !open) { - return; + function htmlOnLeave(event: MouseEvent) { + if (isHoverOpen) { + context.onOpenChange(false, event, "hover"); } + } - const onLeave = (event: MouseEvent) => { - if (!isHoverOpen) { - return; - } - onOpenChange(false, event, "hover"); - }; + $effect(() => { + if (!enabled) return; + if (!handleClose) return; + if (!context.open) return; - const document = getDocument(floating); - document.addEventListener("mouseleave", onLeave); - return () => { - document.removeEventListener("mouseleave", onLeave); - }; + const html = getDocument(context.elements.floating).documentElement; + return on(html, "mouseleave", htmlOnLeave); }); - const closeWithDelay = ( + function closeWithDelay( event: Event, runElseBranch = true, reason: OpenChangeReason = "hover", - ) => { + ) { const closeDelay = getDelay(delay, "close", pointerType); if (closeDelay && !handler) { - clearTimeout(timeout); - timeout = window.setTimeout( - () => onOpenChange(false, event, reason), + window.clearTimeout(openChangeTimeout); + openChangeTimeout = window.setTimeout( + () => context.onOpenChange(false, event, reason), closeDelay, ); } else if (runElseBranch) { - clearTimeout(timeout); - onOpenChange(false, event, reason); + clearTimeout(openChangeTimeout); + context.onOpenChange(false, event, reason); } - }; + } - const cleanupMouseMoveHandler = () => { - unbindMouseMove(); + function cleanupMouseMoveHandler() { + removeMouseMoveListener(); handler = undefined; - }; + } + + function onReferenceMouseEnter(event: MouseEvent) { + window.clearTimeout(openChangeTimeout); + blockMouseMove = false; + + const failedMouseOnlyCheck = + mouseOnly && !isMouseLikePointerType(pointerType); + const failedRestMsCheck = restMs > 0 && !getDelay(delay, "open"); + + if (failedMouseOnlyCheck || failedRestMsCheck) return; + + const openDelay = getDelay(delay, "open", pointerType); + + if (openDelay) { + // if there is an open delay, we set a timeout to open the floating element + // after the delay has passed + openChangeTimeout = window.setTimeout(() => { + if (!context.open) { + context.onOpenChange(true, event, "hover"); + } + }, openDelay); + } else if (!context.open) { + // if no delay and the floating element is not open, we open it immediately + context.onOpenChange(true, event, "hover"); + } + } + + function onReferenceMouseLeave(event: MouseEvent) { + // if opened via a click-like event, we don't handle the mouseleave event + if (isClickLikeOpenEvent) return; + + const doc = getDocument(context.elements.floating); + + // clear the rest timeout and set pending to false + window.clearTimeout(restOpenChangeTimeout); + restTimeoutPending = false; + + if (handleClose && context.data.floatingContext) { + // if not open already, we clear any open change timeouts that may + // be pending + if (!context.open) { + window.clearTimeout(openChangeTimeout); + } + + handler = handleClose({ + ...snapshotFloatingContext(context.data.floatingContext).current, + tree, + x: event.clientX, + y: event.clientY, + onClose: () => { + cleanupPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent) { + closeWithDelay(event, true, "safe-polygon"); + } + }, + }); + + const localHandler = handler; + + // clean up any existing mousemove listeners + doc.addEventListener("mousemove", localHandler); + mouseMoveListener(); + mouseMoveListener = () => { + doc.removeEventListener("mousemove", localHandler); + }; - const clearPointerEvents = () => { - if (!performedPointerEventsMutation) { return; } - const body = getDocument(floating).body; - body.style.pointerEvents = ""; - body.removeAttribute(safePolygonIdentifier); - performedPointerEventsMutation = false; - }; + + // 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.elements.floating, + event.relatedTarget as Element | null, + ) + : true; + + if (shouldClose) { + closeWithDelay(event); + } + } + + $effect(() => { + if (!enabled) return; + + if (isElement(context.elements.domReference) && move) { + return on( + context.elements.domReference, + "mousemove", + (e) => { + onReferenceMouseEnter(e); + }, + { + once: true, + }, + ); + } + }); + + function disableBodyPointerEvents() { + const body = getDocument(context.elements.floating).body; + body.setAttribute(safePolygonIdentifier, ""); + body.style.pointerEvents = "none"; + + return () => { + body.style.pointerEvents = ""; + body.removeAttribute(safePolygonIdentifier); + }; + } // 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 $effect(() => { - if (!enabled) { + if ( + !enabled || + !context.open || + !handleClose?.__options.blockPointerEvents || + !untrack(() => isHoverOpen) + ) { return; } - if (open && handleClose?.__options.blockPointerEvents && isHoverOpen) { - const body = getDocument(floating).body; - body.setAttribute(safePolygonIdentifier, ""); - body.style.pointerEvents = "none"; - performedPointerEventsMutation = true; + const floatingEl = context.elements.floating; + const domReferenceEl = context.elements.domReference; + if (!isElement(domReferenceEl) || !floatingEl) return; - if (isElement(reference) && floating) { - const ref = reference as unknown as HTMLElement | SVGSVGElement; + const parentContext = tree?.nodes?.find( + (node) => node.id === parentId, + )?.context; - // const parentFloating = tree?.nodesRef.current.find((node) => node.id === parentId)?.context - // ?.elements.floating; + if (parentContext) { + parentContext["~position"].setFloatingPointerEvents("inherit"); + } - // if (parentFloating) { - // parentFloating.style.pointerEvents = ''; - // } + clearPointerEvents = disableBodyPointerEvents(); + domReferenceEl.style.pointerEvents = "auto"; - ref.style.pointerEvents = "auto"; - floating.style.pointerEvents = "auto"; + context["~position"].setFloatingPointerEvents("auto"); + const body = getDocument(context.elements.floating).body; - return () => { - ref.style.pointerEvents = ""; - floating.style.pointerEvents = ""; - }; - } - } + return () => { + body.style.pointerEvents = ""; + domReferenceEl.style.pointerEvents = ""; + context["~position"].setFloatingPointerEvents(undefined); + }; }); $effect(() => { - if (!open) { - pointerType = undefined; - cleanupMouseMoveHandler(); - clearPointerEvents(); - } + if (context.open) return; + pointerType = undefined; + restTimeoutPending = false; + cleanupMouseMoveHandler(); + cleanupPointerEvents(); }); $effect(() => { + [enabled, context.elements.domReference]; return () => { cleanupMouseMoveHandler(); - clearTimeout(timeout); - clearTimeout(restTimeout); - clearPointerEvents(); + window.clearTimeout(openChangeTimeout); + window.clearTimeout(restOpenChangeTimeout); + cleanupPointerEvents(); }; }); - return { - get reference() { - if (!enabled) { - return {}; - } - - const onmouseenter = (event: MouseEvent) => { - clearTimeout(timeout); - blockMouseMove = false; - - if ( - (mouseOnly && !isMouseLikePointerType(pointerType)) || - (restMs > 0 && !getDelay(delay, "open")) - ) { - return; - } - - const openDelay = getDelay(delay, "open", pointerType); - - if (openDelay) { - timeout = window.setTimeout(() => { - onOpenChange(true, event, "hover"); - }, openDelay); - } else { - onOpenChange(true, event, "hover"); - } - }; - return { - onpointerdown: (event: PointerEvent) => { - pointerType = event.pointerType; - }, - onpointerenter: (event: PointerEvent) => { - pointerType = event.pointerType; - }, - onmouseenter, - onmousemove: (event: MouseEvent) => { - if (move) { - onmouseenter(event); - } - function handleMouseMove() { - if (!blockMouseMove) { - onOpenChange(true, event, "hover"); - } - } + function handleReferenceMouseMove(event: MouseEvent) { + /** + * if we aren't blocking the mousemove event and the floating element isn't open, + * we open the floating element on mousemove via the reference. + */ + if (!blockMouseMove && !context.open) { + context.onOpenChange(true, event, "hover"); + } + } - if (mouseOnly && !isMouseLikePointerType(pointerType)) { - return; - } + /** + * Set the latest pointer type that triggered an event on the + * reference element. + */ + function setPointerType(event: PointerEvent) { + pointerType = isPointerType(event.pointerType) + ? event.pointerType + : undefined; + } - if (open || restMs === 0) { - return; - } + function isInsignificantMovement(event: MouseEvent) { + return ( + restTimeoutPending && event.movementX ** 2 + event.movementY ** 2 < 2 + ); + } - clearTimeout(restTimeout); + function onReferenceMouseMove(event: MouseEvent) { + if (mouseOnly && !isMouseLikePointerType(pointerType)) return; + // if the floating element is open, or the rest timeout is 0, we don't + // do anything here. + if (context.open || restMs === 0) return; + + // ignore insignificant mouse movements to account for tremors + if (isInsignificantMovement(event)) return; + + // clear open change rest timeout + window.clearTimeout(restOpenChangeTimeout); + + // if it is a touch event, we cut to the chase and handle the mousemove + if (pointerType === "touch") { + handleReferenceMouseMove(event); + } else { + // otherwise, we set a timeout to handle the mousemove event + // based on the restMs prop + restTimeoutPending = true; + restOpenChangeTimeout = window.setTimeout(() => { + handleReferenceMouseMove(event); + }, restMs); + } + } - if (pointerType === "touch") { - handleMouseMove(); - } else { - restTimeout = window.setTimeout(handleMouseMove, restMs); - } - }, - onmouseleave: (event: MouseEvent) => { - if (!isClickLikeOpenEvent) { - unbindMouseMove(); - - const doc = getDocument(floating); - clearTimeout(restTimeout); - - if (handleClose) { - // Prevent clearing `onScrollMouseLeave` timeout. - if (!open) { - clearTimeout(timeout); - } - - handler = handleClose({ - ...context, - // tree, - x: event.clientX, - y: event.clientY, - onClose() { - clearPointerEvents(); - cleanupMouseMoveHandler(); - 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(floating, event.relatedTarget as Element | null) - : true; - if (shouldClose) { - closeWithDelay(event); - } - } + const reference: ElementProps["reference"] = { + onpointerdown: setPointerType, + onpointerenter: setPointerType, + onmouseenter: onReferenceMouseEnter, + onmousemove: onReferenceMouseMove, + onmouseleave: onReferenceMouseLeave, + }; - if (open && !isClickLikeOpenEvent) { - handleClose?.({ - ...context, - // tree, - x: event.clientX, - y: event.clientY, - onClose() { - clearPointerEvents(); - cleanupMouseMoveHandler(); - closeWithDelay(event); - }, - })(event); - } - }, - }; + const floating: ElementProps["floating"] = { + onmouseenter: () => { + // when we enter the floating element, we clear any existing open change timeouts + // to prevent a close event from firing + window.clearTimeout(openChangeTimeout); + }, + onmouseleave: (event) => { + // when we leave, if not opened via a click-like event, we close it. + if (!isClickLikeOpenEvent) { + closeWithDelay(event, false); + } }, + }; + return { + get reference() { + if (!enabled) return {}; + return reference; + }, get floating() { - if (!enabled) { - return {}; - } - return { - onmouseenter() { - clearTimeout(timeout); - }, - onmouseleave(event: MouseEvent) { - if (!isClickLikeOpenEvent) { - handleClose?.({ - ...context, - // tree, - x: event.clientX, - y: event.clientY, - onClose() { - clearPointerEvents(); - cleanupMouseMoveHandler(); - closeWithDelay(event); - }, - })(event); - } - closeWithDelay(event, false); - }, - }; + if (!enabled) return {}; + return floating; }, }; } -export type { UseHoverOptions }; -export { useHover }; +export type { UseHoverOptions, HandleCloseFn }; +export { useHover, getDelay }; 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 9480354d..e280646c 100644 --- a/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts +++ b/packages/floating-ui-svelte/src/hooks/use-interactions.svelte.ts @@ -1,4 +1,5 @@ import type { HTMLAttributes } from "svelte/elements"; +import { FOCUSABLE_ATTRIBUTE } from "../internal/get-floating-focus-element.js"; const ACTIVE_KEY = "active"; const SELECTED_KEY = "selected"; @@ -10,21 +11,21 @@ interface ExtendedUserProps { interface ElementProps { reference?: HTMLAttributes; - floating?: HTMLAttributes; + floating?: HTMLAttributes; item?: - | HTMLAttributes - | ((props: ExtendedUserProps) => HTMLAttributes); + | HTMLAttributes + | ((props: ExtendedUserProps) => HTMLAttributes); } interface UseInteractionsReturn { getReferenceProps: ( - userProps?: HTMLAttributes, + userProps?: Record, ) => Record; getFloatingProps: ( - userProps?: HTMLAttributes, + userProps?: Record, ) => Record; getItemProps: ( - userProps?: Omit, "selected" | "active"> & + userProps?: Omit, "selected" | "active"> & ExtendedUserProps, ) => Record; } @@ -44,7 +45,10 @@ function mergeProps( } return { - ...(elementKey === "floating" && { tabIndex: -1 }), + ...(elementKey === "floating" && { + tabindex: -1, + [FOCUSABLE_ATTRIBUTE]: "", + }), ...domUserProps, ...propsList .map((value) => { @@ -56,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; @@ -87,28 +90,32 @@ function mergeProps( }; } -function useInteractions( - propsList: Array = [], -): UseInteractionsReturn { - const getReferenceProps = $derived((userProps?: HTMLAttributes) => { - return mergeProps(userProps, propsList, "reference"); +class Interactions { + constructor(private readonly propsList: Array = []) {} + + getReferenceProps = $derived((userProps?: HTMLAttributes) => { + return mergeProps(userProps, this.propsList, "reference"); }); - const getFloatingProps = $derived((userProps?: HTMLAttributes) => { - return mergeProps(userProps, propsList, "floating"); + getFloatingProps = $derived((userProps?: HTMLAttributes) => { + return mergeProps(userProps, this.propsList, "floating"); }); - const getItemProps = $derived( + getItemProps = $derived( ( userProps?: Omit, "selected" | "active"> & ExtendedUserProps, ) => { - return mergeProps(userProps, propsList, "item"); + return mergeProps(userProps, this.propsList, "item"); }, ); +} - return { getReferenceProps, getFloatingProps, getItemProps }; +function useInteractions( + propsList: Array = [], +): UseInteractionsReturn { + return new Interactions(propsList); } export type { UseInteractionsReturn, ElementProps, ExtendedUserProps }; -export { useInteractions }; +export { useInteractions, Interactions }; 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..70d88bce --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-list-navigation.svelte.ts @@ -0,0 +1,935 @@ +import { DEV } from "esm-env"; +import { extract } from "../internal/extract.js"; +import type { Boxed, Dimensions, MaybeGetter } from "../types.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, + 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"; +import type { FloatingContextData } from "./use-floating-context.svelte.js"; +import type { FloatingRootContext } from "./use-floating-root-context.svelte.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; +} + +function useListNavigation( + context: FloatingContextData | 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.elements.floating), + ); + const parentId = useFloatingParentNodeId(); + const tree = useFloatingTree(); + const typeableComboboxReference = $derived( + isTypeableCombobox(context.elements.domReference), + ); + + const hasActiveIndex = $derived(activeIndex != null); + + const ariaActiveDescendantProp = $derived.by(() => { + if (virtual && context.open && hasActiveIndex) { + return { + "aria-activedescendant": virtualId || activeId, + }; + } + return {}; + }); + + 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.elements.floating; + let focusItemOnOpen = $state(focusItemOnOpenProp); + + const onNavigate = () => { + onNavigateProp?.(index === -1 ? null : index); + }; + + if (DEV) { + if (allowEscape) { + if (!loop) { + warn("`useListNavigation` looping must be enabled to allow escaping"); + } + + if (!virtual) { + warn("`useListNavigation` must be virtual to allow escaping"); + } + } + + if (orientation === "vertical" && cols > 1) { + warn( + "In grid list navigation mode (`cols` > 1), the `orientation` should", + 'be either "horizontal" or "both".', + ); + } + } + + // 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.elements.floating, + () => selectedIndex, + ], + (_, [__, ___, prevFloating]) => { + if (!enabled) return; + const prevMounted = !!prevFloating; + if (context.open && context.elements.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(); + } + }, + ); + + // Sync `activeIndex` to be the focused item while the floating element is + // open. + watch.pre( + [ + () => enabled, + () => context.open, + () => context.elements.floating, + () => activeIndex, + () => selectedIndex, + () => nested, + () => $state.snapshot(listRef), + () => orientation, + () => rtl, + () => disabledIndices, + ], + (_, [__, prevOpen, prevFloating]) => { + const prevMounted = !!prevFloating; + if (!enabled) return; + if (!context.open) return; + if (!context.elements.floating) return; + + if (activeIndex == null) { + forceSyncFocus = false; + + if (selectedIndex != null) { + return; + } + + // Reset while the floating element was open (e.g. the list changed). + if (prevMounted) { + index = -1; + focusItem(); + } + + // 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(); + } + } else if (!isIndexOutOfBounds(listRef, activeIndex)) { + index = activeIndex; + focusItem(); + 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. + + $effect.pre(() => { + if (!enabled || context.elements.floating || !tree || virtual || !mounted) + return; + + const nodes = tree.nodes; + const parent = nodes.find((node) => node.id === parentId)?.context?.elements + .floating; + const activeEl = activeElement(getDocument(context.elements.floating)); + const treeContainsActiveEl = nodes.some( + (node) => + node.context && contains(node.context.elements.floating, activeEl), + ); + + if (parent && !treeContainsActiveEl && isPointerModality) { + parent.focus({ preventScroll: true }); + } + }); + + watch.pre( + [() => enabled, () => 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; + } + }; + + tree.events.on("virtualfocus", handleVirtualFocus); + + return () => { + tree.events.off("virtualfocus", handleVirtualFocus); + }; + }, + ); + + $effect.pre(() => { + mounted = !!context.elements.floating; + }); + + $effect.pre(() => { + focusItemOnOpen = focusItemOnOpenProp; + }); + + function focusItem() { + const runFocus = (item: HTMLElement) => { + if (virtual) { + activeId = item.id; + tree?.events.emit("virtualfocus", item); + if (virtualItemRef) { + virtualItemRef.current = item; + } + } else { + enqueueFocus(item, { + sync: forceSyncFocus, + preventScroll: true, + }); + } + }; + + const initialItem = listRef[index]; + + if (initialItem) { + runFocus(initialItem); + } + + const scheduler = forceSyncFocus + ? (v: () => void) => v() + : requestAnimationFrame; + + scheduler(() => { + const waitedItem = listRef[index] || initialItem; + + if (!waitedItem) return; + + if (!initialItem) { + runFocus(waitedItem); + } + + const scrollIntoViewOptions = scrollItemIntoView; + const shouldScrollIntoView = + scrollIntoViewOptions && + item && + (forceScrollIntoView || !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, + ); + } + }); + } + + 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 + // 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 (!context.open && event.currentTarget === floatingFocusElement) return; + + if (nested && isCrossOrientationCloseKey(event.key, orientation, rtl)) { + stopEvent(event); + context.onOpenChange(false, event, "list-navigation"); + + if (isHTMLElement(context.elements.domReference)) { + if (virtual) { + tree?.events.emit("virtualfocus", context.elements.domReference); + } else { + context.elements.domReference.focus(); + } + } + + return; + } + + const currentIndex = index; + const filteredListRef = listRef.filter((item) => item !== null); + const minIndex = getMinIndex(filteredListRef, disabledIndices); + const maxIndex = getMaxIndex(filteredListRef, disabledIndices); + + if (!typeableComboboxReference) { + if (event.key === "Home") { + stopEvent(event); + index = minIndex; + onNavigate(); + } + + if (event.key === "End") { + stopEvent(event); + index = maxIndex; + onNavigate(); + } + } + + // Grid navigation. + if (cols > 1) { + const sizes = + itemSizes || + Array.from({ length: filteredListRef.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, cols, dense); + const minGridIndex = cellMap.findIndex( + (index) => + index != null && !isDisabled(filteredListRef, index, disabledIndices), + ); + // last enabled index + const maxGridIndex = cellMap.reduce( + (foundIndex: number, index, cellIndex) => + index != null && !isDisabled(filteredListRef, index, disabledIndices) + ? cellIndex + : foundIndex, + -1, + ); + + const localIndex = + cellMap[ + getGridNavigatedIndex( + cellMap.map((itemIndex) => + itemIndex != null ? filteredListRef[itemIndex] : null, + ), + { + event, + orientation, + loop, + rtl, + cols, + // treat undefined (empty grid spaces) as disabled indices so we + // don't end up in them + disabledIndices: getCellIndices( + [ + ...(disabledIndices || + filteredListRef.map((_, index) => + isDisabled(filteredListRef, index) ? index : undefined, + )), + undefined, + ], + cellMap, + ), + minIndex: minGridIndex, + maxIndex: maxGridIndex, + prevIndex: getCellIndexOfCorner( + index > maxIndex ? minIndex : index, + sizes, + cellMap, + 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 === (rtl ? ARROW_LEFT : ARROW_RIGHT) + ? "tr" + : "tl", + ), + stopEvent: true, + }, + ) + ]; + + if (localIndex != null) { + index = localIndex; + onNavigate(); + } + + if (orientation === "both") return; + } + + if (isMainOrientationKey(event.key, orientation)) { + stopEvent(event); + + // Reset the index if no item is focused. + if ( + context.open && + !virtual && + isElement(event.currentTarget) && + activeElement(event.currentTarget.ownerDocument) === event.currentTarget + ) { + index = isMainOrientationToEndKey(event.key, orientation, rtl) + ? minIndex + : maxIndex; + onNavigate(); + return; + } + + if (isMainOrientationToEndKey(event.key, orientation, rtl)) { + if (loop) { + index = + currentIndex >= maxIndex + ? allowEscape && currentIndex !== filteredListRef.length + ? -1 + : minIndex + : findNonDisabledIndex(filteredListRef, { + startingIndex: currentIndex, + disabledIndices: disabledIndices, + }); + } else { + index = Math.min( + maxIndex, + findNonDisabledIndex(filteredListRef, { + startingIndex: currentIndex, + disabledIndices: disabledIndices, + }), + ); + } + } else { + if (loop) { + index = + currentIndex <= minIndex + ? allowEscape && currentIndex !== -1 + ? filteredListRef.length + : maxIndex + : findNonDisabledIndex(filteredListRef, { + startingIndex: currentIndex, + decrement: true, + disabledIndices: disabledIndices, + }); + } else { + index = Math.max( + minIndex, + findNonDisabledIndex(filteredListRef, { + startingIndex: currentIndex, + decrement: true, + disabledIndices: disabledIndices, + }), + ); + } + } + + if (isIndexOutOfBounds(filteredListRef, index)) { + index = -1; + } + + onNavigate(); + } + }; + + const floatingOnPointerMove: PointerEventHandler = () => { + isPointerModality = 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; + } + }; + + const checkVirtualPointer = (event: PointerEvent) => { + // `pointerdown` fires first, reset the state then perform the checks. + focusItemOnOpen = focusItemOnOpenProp; + if (focusItemOnOpenProp === "auto" && isVirtualPointerEvent(event)) { + focusItemOnOpen = true; + } + }; + + 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, + orientation, + rtl, + ); + const isCrossCloseKey = isCrossOrientationCloseKey( + event.key, + orientation, + rtl, + ); + const isMainKey = isMainOrientationKey(event.key, orientation); + const isNavigationKey = + (nested ? isCrossOpenKey : isMainKey) || + event.key === "Enter" || + event.key.trim() === ""; + + if (virtual && isOpen) { + const rootNode = tree?.nodes.find((node) => node.parentId == null); + const deepestNode = + tree && rootNode ? getDeepestNode(tree.nodes, rootNode.id) : null; + + if (isMoveKey && deepestNode && virtualItemRef) { + const eventObject = new KeyboardEvent("keydown", { + key: event.key, + bubbles: true, + }); + + if (isCrossOpenKey || isCrossCloseKey) { + const isCurrentTarget = + deepestNode.context?.elements.domReference === event.currentTarget; + const dispatchItem = + isCrossCloseKey && !isCurrentTarget + ? deepestNode.context?.elements.domReference + : isCrossOpenKey + ? listRef.find((item) => item?.id === activeId) + : null; + + if (dispatchItem) { + stopEvent(event); + dispatchItem.dispatchEvent(eventObject); + virtualId = undefined; + } + } + + if ((isMainKey || isHomeOrEndKey) && deepestNode.context) { + if ( + deepestNode.context.open && + deepestNode.parentId && + event.currentTarget !== deepestNode.context.elements.domReference + ) { + stopEvent(event); + deepestNode.context.elements.domReference?.dispatchEvent( + eventObject, + ); + return; + } + } + } + + return commonOnKeyDown(event); + } + + // If a floating element should not open on arrow key down, avoid + // setting `activeIndex` while it's closed. + if (!isOpen && !openOnArrowKeyDown && isArrowKey) return; + + if (isNavigationKey) { + key = nested && isMainKey ? null : event.key; + } + + if (nested) { + if (isCrossOpenKey) { + stopEvent(event); + + if (isOpen) { + index = getMinIndex(listRef, disabledIndices); + onNavigate(); + } else { + context.onOpenChange(true, event, "list-navigation"); + } + } + + return; + } + + if (isMainKey) { + if (selectedIndex != null) { + index = selectedIndex; + } + + stopEvent(event); + + if (!isOpen && openOnArrowKeyDown) { + context.onOpenChange(true, event, "list-navigation"); + } else { + commonOnKeyDown(event); + } + + if (isOpen) { + onNavigate(); + } + } + }; + + const referenceOnFocus = () => { + if (context.open && !virtual) { + index = -1; + onNavigate(); + } + }; + + const reference: ElementProps["reference"] = $derived({ + ...ariaActiveDescendantProp, + onkeydown: referenceOnKeyDown, + onfocus: referenceOnFocus, + onpointerdown: checkVirtualPointer, + onpointerenter: checkVirtualPointer, + onmousedown: checkVirtualMouse, + onclick: checkVirtualMouse, + }); + + return { + get floating() { + if (!enabled) return {}; + return floating; + }, + get item() { + if (!enabled) return {}; + return item; + }, + get reference() { + if (!enabled) return {}; + return 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); +} + +export { useListNavigation }; +export type { UseListNavigationOptions }; diff --git a/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts new file mode 100644 index 00000000..424a8c7a --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-position.svelte.ts @@ -0,0 +1,174 @@ +import { + type ComputePositionConfig, + type Middleware, + type MiddlewareData, + type Placement, + 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 { Boxed, ReferenceType } from "../types.js"; +import type { PropertiesHyphen } from "csstype"; +import type { FloatingRootContext } from "./use-floating-root-context.svelte.js"; +import type { FloatingOptions } from "./use-floating-options.svelte.js"; + +interface UsePositionData { + /** + * The x-coordinate of the floating element. + */ + x: number; + + /** + * The y-coordinate of the floating element. + */ + y: number; + + /** + * The stateful placement, which can be different from the initial `placement` passed as options. + */ + placement: Placement; + + /** + * The stateful strategy, which can be different from the initial `strategy` passed as options. + */ + strategy: Strategy; + + /** + * Additional data from middleware. + */ + middlewareData: MiddlewareData; + + /** + * The boolean that let you know if the floating element has been positioned. + */ + isPositioned: boolean; +} + +export interface PositionState + extends ReturnType> {} + +/** + * Manages the positioning of floating elements. + */ +export function usePosition( + opts: FloatingOptions, + rootContext: FloatingRootContext, + getPositionReference: () => RT | null, +) { + const referenceEl = $derived( + getPositionReference() || opts.reference.current || null, + ); + const data: UsePositionData = $state({ + x: 0, + y: 0, + strategy: opts.strategy.current, + placement: opts.placement.current, + middlewareData: {}, + isPositioned: false, + }); + + let floatingPointerEvents = $state.raw< + Boxed + >({ current: undefined }); + + function setFloatingPointerEvents( + pointerEvents: PropertiesHyphen["pointer-events"] | undefined, + ) { + floatingPointerEvents = { current: pointerEvents }; + } + + const floatingStyles = $derived.by(() => { + const pointerEvents = $state.snapshot(floatingPointerEvents); + const initialStyles: PropertiesHyphen = { + position: opts.strategy.current, + left: "0px", + top: "0px", + ...(pointerEvents.current && { + "pointer-events": pointerEvents.current, + }), + }; + + if (!opts.floating.current) { + return styleObjectToString(initialStyles); + } + + const x = roundByDPR(opts.floating.current, data.x); + const y = roundByDPR(opts.floating.current, data.y); + + if (opts.transform.current) { + return styleObjectToString({ + ...initialStyles, + transform: `translate(${x}px, ${y}px)`, + ...(getDPR(opts.floating.current) >= 1.5 && { + willChange: "transform", + }), + }); + } + + return styleObjectToString({ + position: opts.strategy.current, + left: `${x}px`, + top: `${y}px`, + ...(pointerEvents.current && { + "pointer-events": pointerEvents.current, + }), + }); + }); + + async function update() { + if (!referenceEl || !opts.floating.current) return; + + const config: ComputePositionConfig = { + placement: opts.placement.current, + strategy: opts.strategy.current, + middleware: opts.middleware.current, + }; + + const position = await computePosition( + referenceEl, + opts.floating.current, + config, + ); + + data.x = position.x; + data.y = position.y; + data.placement = position.placement; + data.strategy = position.strategy; + data.middlewareData = position.middlewareData; + data.isPositioned = rootContext.open !== false; + } + + $effect.pre(() => { + if (rootContext.open || !data.isPositioned) return; + data.isPositioned = false; + }); + + $effect.pre(() => { + if (referenceEl && opts.floating.current) { + if (opts?.whileElementsMounted) { + return opts.whileElementsMounted( + referenceEl, + opts.floating.current, + update, + ); + } + + update(); + } + }); + + return { + get referenceEl() { + return referenceEl; + }, + data, + setFloatingPointerEvents, + get floatingStyles() { + return floatingStyles; + }, + update, + }; +} + +export type { UsePositionData }; 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..33493162 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,7 @@ -import type { FloatingContext } from "./use-floating.svelte.js"; +import { useFloatingParentNodeId } from "../components/floating-tree/hooks.svelte.js"; +import { extract } from "../internal/extract.js"; +import type { MaybeGetter } from "../types.js"; +import type { FloatingContextData } from "./use-floating-context.svelte.js"; import { useId } from "./use-id.js"; import type { ElementProps, @@ -22,12 +25,12 @@ interface UseRoleOptions { * handlers. * @default true */ - enabled?: boolean; + enabled?: MaybeGetter; /** * The role of the floating element. * @default 'dialog' */ - role?: AriaRole | ComponentRole; + role?: MaybeGetter; } const componentRoleToAriaRoleMap = new Map< @@ -40,96 +43,96 @@ const componentRoleToAriaRoleMap = new Map< ]); function useRole( - context: FloatingContext, - options: UseRoleOptions = {}, + context: FloatingContextData, + opts: UseRoleOptions = {}, ): ElementProps { - const { open, floatingId } = $derived(context); - - const { enabled = true, role = "dialog" } = $derived(options); - + 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, ); - - // FIXME: Uncomment the commented code once useId and useFloatingParentNodeId are implemented. + const parentId = useFloatingParentNodeId(); + const isNested = parentId != null; const referenceId = useId(); - const parentId = undefined; - // const parentId = useFloatingParentNodeId(); - const isNested = parentId != null; + const reference: ElementProps["reference"] = $derived.by(() => { + if (ariaRole === "tooltip" || role === "label") { + return { + [`aria-${role === "label" ? "labelledby" : "describedby"}`]: + context.open ? context.floatingId : undefined, + }; + } - const floatingProps = $derived({ - id: floatingId, - ...(ariaRole && { role: ariaRole }), + return { + "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" }), + }; }); - return { - // @ts-expect-error - variable prop is not specific enough - get reference() { - if (!enabled) { - return {}; - } - if (ariaRole === "tooltip" || role === "label") { - return { - [`aria-${role === "label" ? "labelledby" : "describedby"}` as const]: - open ? floatingId : undefined, - }; - } - return { - "aria-expanded": open ? "true" : "false", - "aria-haspopup": ariaRole === "alertdialog" ? "dialog" : ariaRole, - "aria-controls": open ? 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" }), - }; - }, - get floating() { - if (!enabled) { - return {}; - } - if (ariaRole === "tooltip" || role === "label") { - return floatingProps; - } - return { - ...floatingProps, - ...(ariaRole === "menu" && { "aria-labelledby": referenceId }), + const floating: ElementProps["floating"] = $derived.by(() => { + const floatingProps = { + id: context.floatingId, + ...(ariaRole && { role: ariaRole }), + }; + + if (ariaRole === "tooltip" || role === "label") { + return floatingProps; + } + + return { + ...floatingProps, + ...(ariaRole === "menu" && { + "aria-labelledby": referenceId, + }), + }; + }); + + const item: ElementProps["item"] = $derived.by(() => { + return ({ active, selected }: ExtendedUserProps) => { + const commonProps = { + role: "option", + ...(active && { id: `${context.floatingId}-option` }), }; - }, - get item() { - if (!enabled) { - return {}; - } - return ({ active, selected }: ExtendedUserProps) => { - const commonProps = { - role: "option", - ...(active && { id: `${context.floatingId}-option` }), - }; - // For `menu`, we are unable to tell if the item is a `menuitemradio` - // or `menuitemcheckbox`. For backwards-compatibility reasons, also - // avoid defaulting to `menuitem` as it may overwrite custom role props. - switch (role) { - case "select": - return { - ...commonProps, - "aria-selected": active && selected, - }; - case "combobox": { - return { - ...commonProps, - ...(active && { "aria-selected": true }), - }; - } + switch (role) { + case "select": + return { + ...commonProps, + "aria-selected": active && selected, + }; + case "combobox": { + return { + ...commonProps, + ...(active && { "aria-selected": true }), + }; } + } - return {}; - }; + return {}; + }; + }); + + return { + get reference() { + if (!enabled) return {}; + return reference; + }, + get item() { + if (!enabled) return {}; + return item; + }, + get floating() { + if (!enabled) return {}; + return floating; }, }; } 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..b85f5c5c --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-transition.svelte.ts @@ -0,0 +1,238 @@ +import type { PropertiesHyphen } from "csstype"; +import { extract } from "../internal/extract.js"; +import { watch } from "../internal/watch.svelte.js"; +import type { Boxed, Getter, MaybeGetter } from "../types.js"; +import type { Placement, Side } from "@floating-ui/utils"; +import { styleObjectToString } from "../internal/style-object-to-string.js"; +import type { FloatingContextData } from "./use-floating-context.svelte.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 = window.setTimeout(() => { + isMounted = false; + }, durationMs); + return () => window.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"; + +/** + * Provides a status string to apply CSS transitions to a floating element, + * correctly handling placement-aware transitions. + */ +function useTransitionStatus( + context: FloatingContextData, + opts: UseTransitionStatusOptions = {}, +): { isMounted: boolean; status: TransitionStatus } { + const duration = $derived(extract(opts.duration, 250)); + const closeDuration = $derived.by(() => { + if (typeof duration === "number") { + return duration; + } + return duration.close || 0; + }); + + const isMounted = useDelayUnmount({ + open: () => context.open, + durationMs: () => closeDuration, + }); + + let status: TransitionStatus = $state("unmounted"); + + $effect.pre(() => { + if (!isMounted.current && status === "close") { + status = "unmounted"; + } + }); + + $effect.pre(() => { + if (!context.elements.floating) return; + if (context.open) { + status = "initial"; + + const frame = requestAnimationFrame(() => { + status = "open"; + }); + + return () => { + cancelAnimationFrame(frame); + }; + } + + status = "close"; + }); + + return { + get isMounted() { + return isMounted.current; + }, + get status() { + return status; + }, + }; +} + +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; +} + +function useTransitionStyles( + context: FloatingContextData, + 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 duration.open || 0; + }); + const closeDuration = $derived.by(() => { + if (typeof duration === "number") { + return duration; + } + return duration.close || 0; + }); + 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 (status === "open") { + styles = { + "transition-property": Object.keys(openStyles).join(", "), + "transition-duration": `${openDuration}ms`, + ...commonStyles, + ...openStyles, + }; + } + + if (status === "close") { + const localStyles = closeStyles || initialStyles; + styles = { + "transition-property": Object.keys(localStyles).join(", "), + "transition-duration": `${closeDuration}ms`, + ...commonStyles, + ...localStyles, + }; + } + }, + ); + + return { + get styles() { + return styleObjectToString(styles); + }, + + get isMounted() { + return transitionStatus.isMounted; + }, + }; +} + +export { useTransitionStyles, useTransitionStatus }; +export type { UseTransitionStatusOptions, UseTransitionStylesOptions }; 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..28941934 --- /dev/null +++ b/packages/floating-ui-svelte/src/hooks/use-typeahead.svelte.ts @@ -0,0 +1,233 @@ +import { stopEvent } from "../internal/dom.js"; +import { extract } from "../internal/extract.js"; +import type { MaybeGetter } from "../types.js"; +import type { FloatingContextData } from "./use-floating-context.svelte.js"; +import type { FloatingRootContext } from "./use-floating-root-context.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: FloatingContextData | 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; + + $effect.pre(() => { + if (!context.open) return; + window.clearTimeout(timeoutId); + matchIndex = null; + str = ""; + }); + + $effect.pre(() => { + 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 isOpen = 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 (isOpen && 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 fec9bd7c..573e47f5 100644 --- a/packages/floating-ui-svelte/src/index.ts +++ b/packages/floating-ui-svelte/src/index.ts @@ -4,10 +4,39 @@ export * from "./components/floating-arrow.svelte"; export { default as FloatingArrow } from "./components/floating-arrow.svelte"; +export * from "./components/floating-portal/floating-portal.svelte"; +export { default as FloatingPortal } from "./components/floating-portal/floating-portal.svelte"; +export * from "./components/floating-portal/hooks.svelte.js"; + +export * from "./components/floating-tree/floating-tree.svelte"; +export { default as FloatingTree } from "./components/floating-tree/floating-tree.svelte"; +export * from "./components/floating-tree/floating-node.svelte"; +export { default as FloatingNode } from "./components/floating-tree/floating-node.svelte"; +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"; + +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"; + +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 */ export * from "./hooks/use-click.svelte.js"; +export * from "./hooks/use-client-point.svelte.js"; export * from "./hooks/use-dismiss.svelte.js"; export * from "./hooks/use-floating.svelte.js"; export * from "./hooks/use-focus.svelte.js"; @@ -15,6 +44,10 @@ 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 "./hooks/use-list-navigation.svelte.js"; +export * from "./hooks/use-typeahead.svelte.js"; +export * from "./safe-polygon.js"; /** * Types diff --git a/packages/floating-ui-svelte/src/internal/active-element.svelte.ts b/packages/floating-ui-svelte/src/internal/active-element.svelte.ts new file mode 100644 index 00000000..2d381397 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/active-element.svelte.ts @@ -0,0 +1,71 @@ +import { BROWSER } from "esm-env"; +import { on } from "svelte/events"; +import { createSubscriber } from "svelte/reactivity"; + +const defaultWindow = BROWSER ? window : undefined; + +/** + * Handles getting the active element in a document or shadow root. + * If the active element is within a shadow root, it will traverse the shadow root + * to find the active element. + * If not, it will return the active element in the document. + * + * @param document A document or shadow root to get the active element from. + * @returns The active element in the document or shadow root. + */ +function getActiveElement(document: DocumentOrShadowRoot): Element | null { + let activeElement = document.activeElement; + + while (activeElement?.shadowRoot) { + const node = activeElement.shadowRoot.activeElement; + if (node === activeElement) break; + activeElement = node; + } + + return activeElement; +} + +interface ActiveElementOptions { + document?: Document; + window?: Window; +} + +class ActiveElement { + readonly #document?: DocumentOrShadowRoot; + readonly #subscribe?: () => void; + + constructor(options: ActiveElementOptions = {}) { + const { window = defaultWindow, document = window?.document } = options; + if (window === undefined) return; + + this.#document = document; + this.#subscribe = createSubscriber((update) => { + const cleanupFocusIn = on(window, "focusin", update); + const cleanupFocusOut = on(window, "focusout", update); + return () => { + cleanupFocusIn(); + cleanupFocusOut(); + }; + }); + } + + get current(): Element | null { + this.#subscribe?.(); + if (!this.#document) return null; + return getActiveElement(this.#document); + } +} + +/** + * An object holding a reactive value that is equal to `document.activeElement`. + * It automatically listens for changes, keeping the reference up to date. + * + * If you wish to use a custom document or shadowRoot, you should use + * [useActiveElement](https://runed.dev/docs/utilities/active-element) instead. + * + * @see {@link https://runed.dev/docs/utilities/active-element} + */ +const reactiveActiveElement = new ActiveElement(); + +export { reactiveActiveElement, ActiveElement }; +export type { ActiveElementOptions }; diff --git a/packages/floating-ui-svelte/src/internal/after-sleep.ts b/packages/floating-ui-svelte/src/internal/after-sleep.ts new file mode 100644 index 00000000..25cfa5a9 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/after-sleep.ts @@ -0,0 +1,8 @@ +/** + * A utility function that executes a callback after a specified number of milliseconds. + */ +function afterSleep(ms: number, cb: () => void) { + return setTimeout(cb, ms); +} + +export { afterSleep }; 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/box.svelte.ts b/packages/floating-ui-svelte/src/internal/box.svelte.ts new file mode 100644 index 00000000..862bb207 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/box.svelte.ts @@ -0,0 +1,235 @@ +import type { Getter } from "../types.js"; +import { isFunction, isObject } from "./is.js"; + +type MaybeBoxOrGetter = T | Getter | ReadableBox; + +const BoxSymbol = Symbol("box"); +const isWritableSymbol = Symbol("is-writable"); + +type ReadableBox = { + readonly [BoxSymbol]: true; + readonly current: T; +}; + +type WritableBox = ReadableBox & { + readonly [isWritableSymbol]: true; + current: T; +}; + +/** + * @returns Whether the value is a Box + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function isBox(value: unknown): value is ReadableBox { + return isObject(value) && BoxSymbol in value; +} +/** + * @returns Whether the value is a WritableBox + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function isWritableBox(value: unknown): value is WritableBox { + return box.isBox(value) && isWritableSymbol in value; +} + +/** + * Creates a writable box. + * + * @returns A box with a `current` property which can be set to a new value. + * Useful to pass state to other functions. + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function box(): WritableBox; +/** + * Creates a writable box with an initial value. + * + * @param initialValue The initial value of the box. + * @returns A box with a `current` property which can be set to a new value. + * Useful to pass state to other functions. + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function box(initialValue: T): WritableBox; +function box(initialValue?: unknown) { + let current = $state(initialValue); + + return { + [BoxSymbol]: true, + [isWritableSymbol]: true, + get current() { + return current as unknown; + }, + set current(v: unknown) { + current = v; + }, + }; +} + +/** + * Creates a readonly box + * + * @param getter Function to get the value of the box + * @returns A box with a `current` property whose value is the result of the getter. + * Useful to pass state to other functions. + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function boxWith(getter: () => T): ReadableBox; +/** + * Creates a writable box + * + * @param getter Function to get the value of the box + * @param setter Function to set the value of the box + * @returns A box with a `current` property which can be set to a new value. + * Useful to pass state to other functions. + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function boxWith(getter: () => T, setter: (v: T) => void): WritableBox; +function boxWith(getter: () => T, setter?: (v: T) => void) { + const derived = $derived.by(getter); + + if (setter) { + return { + [BoxSymbol]: true, + [isWritableSymbol]: true, + get current() { + return derived; + }, + set current(v: T) { + setter(v); + }, + }; + } + + return { + [BoxSymbol]: true, + get current() { + return getter(); + }, + }; +} + +/** + * Creates a box from either a static value, a box, or a getter function. + * Useful when you want to receive any of these types of values and generate a boxed version of it. + * + * @returns A box with a `current` property whose value. + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function boxFrom(value: T | WritableBox): WritableBox; +function boxFrom(value: ReadableBox): ReadableBox; +function boxFrom(value: Getter): ReadableBox; +function boxFrom(value: MaybeBoxOrGetter): ReadableBox; +function boxFrom(value: T): WritableBox; +function boxFrom(value: MaybeBoxOrGetter) { + if (box.isBox(value)) return value; + if (isFunction(value)) return box.with(value); + return box(value); +} + +type GetKeys = { + [K in keyof T]: T[K] extends U ? K : never; +}[keyof T]; +type RemoveValues = Omit>; + +type BoxFlatten> = Expand< + RemoveValues< + { + [K in keyof R]: R[K] extends WritableBox ? T : never; + }, + never + > & + RemoveValues< + { + readonly [K in keyof R]: R[K] extends WritableBox + ? never + : R[K] extends ReadableBox + ? T + : never; + }, + never + > +> & + RemoveValues< + { + [K in keyof R]: R[K] extends ReadableBox ? never : R[K]; + }, + never + >; + +/** + * Function that gets an object of boxes, and returns an object of reactive values + * + * @example + * const count = box(0) + * const flat = box.flatten({ count, double: box.with(() => count.current) }) + * // type of flat is { count: number, readonly double: number } + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function boxFlatten>( + boxes: R, +): BoxFlatten { + return Object.entries(boxes).reduce>( + (acc, [key, b]) => { + if (!box.isBox(b)) { + return Object.assign(acc, { [key]: b }); + } + + if (box.isWritableBox(b)) { + Object.defineProperty(acc, key, { + get() { + return b.current; + }, + // biome-ignore lint/suspicious/noExplicitAny: + set(v: any) { + b.current = v; + }, + }); + } else { + Object.defineProperty(acc, key, { + get() { + return b.current; + }, + }); + } + + return acc; + }, + {} as BoxFlatten, + ); +} + +/** + * Function that converts a box to a readonly box. + * + * @example + * const count = box(0) // WritableBox + * const countReadonly = box.readonly(count) // ReadableBox + * + * @see {@link https://runed.dev/docs/functions/box} + */ +function toReadonlyBox(b: ReadableBox): ReadableBox { + if (!box.isWritableBox(b)) return b; + + return { + [BoxSymbol]: true, + get current() { + return b.current; + }, + }; +} + +box.from = boxFrom; +box.with = boxWith; +box.flatten = boxFlatten; +box.readonly = toReadonlyBox; +box.isBox = isBox; +box.isWritableBox = isWritableBox; + +export type { MaybeBoxOrGetter, ReadableBox, WritableBox }; +export { box }; 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/src/internal/context.ts b/packages/floating-ui-svelte/src/internal/context.ts new file mode 100644 index 00000000..0d50cc8e --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/context.ts @@ -0,0 +1,75 @@ +/** + * 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/create-pub-sub.ts b/packages/floating-ui-svelte/src/internal/create-pub-sub.ts index 949bca80..ee91e983 100644 --- a/packages/floating-ui-svelte/src/internal/create-pub-sub.ts +++ b/packages/floating-ui-svelte/src/internal/create-pub-sub.ts @@ -1,7 +1,8 @@ function createPubSub() { const map = new Map void>>(); return { - emit(event: string, data: unknown) { + // biome-ignore lint/suspicious/noExplicitAny: TODO: Type this with the actual structures? + emit(event: string, data: any) { const handlers = map.get(event); if (!handlers) { return; @@ -10,10 +11,16 @@ function createPubSub() { handler(data); } }, - on(event: string, listener: (data: unknown) => void) { + // 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) || []); + }; }, - off(event: string, listener: (data: unknown) => void) { + // 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/internal/dom.ts b/packages/floating-ui-svelte/src/internal/dom.ts index e1c27a73..60f1903b 100644 --- a/packages/floating-ui-svelte/src/internal/dom.ts +++ b/packages/floating-ui-svelte/src/internal/dom.ts @@ -20,16 +20,12 @@ function createAttribute(name: string) { } function contains(parent?: Element | null, child?: Element | null) { - if (!parent || !child) { - return false; - } + if (!parent || !child) return false; const rootNode = child.getRootNode?.(); // First, attempt with faster native method - if (parent.contains(child)) { - return true; - } + if (parent.contains(child)) return true; // then fallback to custom implementation with Shadow DOM support if (rootNode && isShadowRoot(rootNode)) { @@ -47,27 +43,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]; @@ -79,12 +54,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 @@ -109,6 +84,52 @@ 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); +} + +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, @@ -119,4 +140,8 @@ export { isEventTargetWithin, 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/execute-callbacks.ts b/packages/floating-ui-svelte/src/internal/execute-callbacks.ts new file mode 100644 index 00000000..8365339e --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/execute-callbacks.ts @@ -0,0 +1,17 @@ +/** + * Executes an array of callback functions with the same arguments. + * @template T The types of the arguments that the callback functions take. + * @param callbacks array of callback functions to execute. + * @returns A new function that executes all of the original callback functions with the same arguments. + */ +export function executeCallbacks( + ...callbacks: T +): (...args: unknown[]) => void { + return (...args: unknown[]) => { + for (const callback of callbacks) { + if (typeof callback === "function") { + callback(...args); + } + } + }; +} diff --git a/packages/floating-ui-svelte/src/internal/extract.ts b/packages/floating-ui-svelte/src/internal/extract.ts new file mode 100644 index 00000000..037ed550 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/extract.ts @@ -0,0 +1,41 @@ +import type { Getter, MaybeGetter } from "../types.js"; +import { isFunction } from "./is.js"; + +/** + * Extracts the value from a getter or a value. + * Optionally, a default value can be provided. + */ +function extract(value: MaybeGetter): T; +function extract( + value: MaybeGetter, + defaultValue: D, +): Exclude; +function extract( + value: MaybeGetter, + defaultValue?: D, +): D extends undefined ? T : Exclude { + if (isFunction(value)) { + const getter = value as Getter; + + const res = + getter() !== undefined + ? getter() + : defaultValue !== undefined + ? defaultValue + : getter(); + + // biome-ignore lint/suspicious/noExplicitAny: + return res as any; + } + + // biome-ignore lint/suspicious/noExplicitAny: + if (value !== undefined) return value as any; + + // biome-ignore lint/suspicious/noExplicitAny: + if (defaultValue !== undefined) return defaultValue as any; + + // biome-ignore lint/suspicious/noExplicitAny: + return value as any; +} + +export { extract }; 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-children.ts b/packages/floating-ui-svelte/src/internal/get-children.ts new file mode 100644 index 00000000..caecf814 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/get-children.ts @@ -0,0 +1,50 @@ +import type { FloatingNodeType, ReferenceType } from "../types.js"; + +function getChildren( + nodes: Array>, + id: string | undefined, +) { + let allChildren = nodes.filter( + (node) => node.parentId === id && node.context?.open, + ); + let currentChildren = allChildren; + + while (currentChildren.length) { + currentChildren = nodes.filter((node) => + currentChildren?.some( + (n) => node.parentId === n.id && node.context?.open, + ), + ); + + allChildren = allChildren.concat(currentChildren); + } + + return allChildren; +} + +function getDeepestNode( + nodes: Array>, + id: string | undefined, +) { + let deepestNodeId: string | undefined; + let maxDepth = -1; + + function findDeepest(nodeId: string | undefined, depth: number) { + if (depth > maxDepth) { + deepestNodeId = nodeId; + maxDepth = depth; + } + + const children = getChildren(nodes, nodeId); + + for (const child of children) { + findDeepest(child.id, depth + 1); + } + } + + findDeepest(id, 0); + + return nodes.find((node) => node.id === deepestNodeId); +} + +export { getChildren, getDeepestNode }; 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..21442e62 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/get-floating-focus-element.ts @@ -0,0 +1,15 @@ +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/handle-guard-focus.ts b/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts new file mode 100644 index 00000000..87c7ed56 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/handle-guard-focus.ts @@ -0,0 +1,33 @@ +import { afterSleep } from "./after-sleep.js"; + +/** + * + * 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 + * restoring the attribute. + */ +function handleGuardFocus( + guard: HTMLElement | null, + focusOptions?: Parameters[0], +) { + if (!guard) return; + const ariaHidden = guard.getAttribute("aria-hidden"); + guard.removeAttribute("aria-hidden"); + guard.focus(focusOptions); + afterSleep(0, () => { + if (ariaHidden === null) { + guard.setAttribute("aria-hidden", ""); + } else { + guard.setAttribute("aria-hidden", ariaHidden); + } + }); +} + +export { handleGuardFocus }; 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 58% rename from packages/floating-ui-svelte/src/internal/is-typable-element.ts rename to packages/floating-ui-svelte/src/internal/is-typeable-element.ts index a77bb97a..b48262c0 100644 --- a/packages/floating-ui-svelte/src/internal/is-typable-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/is.ts b/packages/floating-ui-svelte/src/internal/is.ts new file mode 100644 index 00000000..cd957d41 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/is.ts @@ -0,0 +1,9 @@ +function isFunction(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === "function"; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +export { isFunction, isObject }; 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..71d44f94 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/log.ts @@ -0,0 +1,24 @@ +import { DEV } from "esm-env"; + +let devMessageSet: Set | undefined; +if (DEV) { + devMessageSet = new Set(); +} + +function warn(...messages: string[]) { + const message = `Floating UI Svelte: ${messages.join(" ")}`; + if (!devMessageSet?.has(message)) { + devMessageSet?.add(message); + console.warn(message); + } +} + +function error(...messages: string[]) { + const message = `Floating UI Svelte: ${messages.join(" ")}`; + if (!devMessageSet?.has(message)) { + devMessageSet?.add(message); + console.error(message); + } +} + +export { warn, error }; 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/snapshot.svelte.ts b/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts new file mode 100644 index 00000000..65572f97 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/snapshot.svelte.ts @@ -0,0 +1,39 @@ +import type { FloatingContextData } from "../hooks/use-floating-context.svelte.js"; +import type { ReferenceType } from "../types.js"; + +// TODO: consider the following: +// Is it worth it to include this as part of the `FloatingContext` instance? +// So users can call context.snapshot() and get an object? +function snapshotFloatingContext( + context: FloatingContextData, +) { + return { + get current(): FloatingContextData { + return { + elements: { + reference: context.elements.reference, + floating: context.elements.floating, + domReference: context.elements.domReference, + }, + x: context.x, + y: context.y, + placement: context.placement, + strategy: context.strategy, + middlewareData: context.middlewareData, + isPositioned: context.isPositioned, + update: context.update, + floatingStyles: context.floatingStyles, + onOpenChange: context.onOpenChange, + open: context.open, + data: context.data, + floatingId: context.floatingId, + events: context.events, + nodeId: context.nodeId, + setPositionReference: context.setPositionReference, + "~position": context["~position"], + }; + }, + }; +} + +export { snapshotFloatingContext }; 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 614f7cbf..cb868954 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 @@ -1,4 +1,6 @@ -import type { PropertiesHyphen } from "csstype"; +import type { Properties, PropertiesHyphen } from "csstype"; +import { error } from "./log.js"; +import parse from "style-to-object"; function styleObjectToString(styleObject: PropertiesHyphen) { return Object.entries(styleObject) @@ -6,4 +8,35 @@ function styleObjectToString(styleObject: PropertiesHyphen) { .join(" "); } -export { styleObjectToString }; +function styleStringToObject( + style: string | null | undefined, +): PropertiesHyphen { + if (!style) return {}; + try { + return parse(style) as PropertiesHyphen; + } catch (err) { + error("Invalid style string provided via `style` prop. No styles applied."); + return {}; + } +} + +type MergeStylesArg = string | PropertiesHyphen | null | undefined; + +function mergeStyles( + ...args: T +): string | undefined { + const mergedStyleObj: PropertiesHyphen = {}; + + for (const arg of args) { + if (arg === null) continue; + if (typeof arg === "string") { + Object.assign(mergedStyleObj, styleStringToObject(arg)); + } else if (arg) { + Object.assign(mergedStyleObj, arg); + } + } + + return styleObjectToString(mergedStyleObj); +} + +export { styleObjectToString, styleStringToObject, mergeStyles }; 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..5e26a453 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/tabbable.ts @@ -0,0 +1,77 @@ +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 activeEl = activeElement(getDocument(container)) as HTMLElement; + + const activeIndex = allTabbable.indexOf(activeEl); + 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 = Array.from( + 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/internal/watch.svelte.ts b/packages/floating-ui-svelte/src/internal/watch.svelte.ts new file mode 100644 index 00000000..236276e7 --- /dev/null +++ b/packages/floating-ui-svelte/src/internal/watch.svelte.ts @@ -0,0 +1,217 @@ +import { untrack } from "svelte"; + +type Getter = () => T; + +function runEffect( + flush: "post" | "pre", + effect: (() => void) | VoidFunction, +): void { + switch (flush) { + case "post": + $effect(effect); + break; + case "pre": + $effect.pre(effect); + break; + } +} + +export type WatchOptions = { + /** + * If `true`, the effect doesn't run until one of the `sources` changes. + * + * @default false + */ + lazy?: boolean; +}; + +function runWatcher( + sources: Getter | Array>, + flush: "post" | "pre", + effect: + | (( + values: T | Array, + previousValues: T | undefined | Array, + ) => void) + | VoidFunction, + options: WatchOptions = {}, +): void { + const { lazy = false } = options; + + // Run the effect immediately if `lazy` is `false`. + let active = !lazy; + + // On the first run, if the dependencies are an array, pass an empty array + // to the previous value instead of `undefined` to allow destructuring. + // + // watch(() => [a, b], ([a, b], [prevA, prevB]) => { ... }); + let previousValues: T | undefined | Array = Array.isArray( + sources, + ) + ? [] + : undefined; + + runEffect(flush, () => { + const values = Array.isArray(sources) + ? sources.map((source) => source()) + : sources(); + + if (!active) { + active = true; + previousValues = values; + return; + } + + const cleanup = untrack(() => effect(values, previousValues)); + previousValues = values; + return cleanup; + }); +} + +function runWatcherOnce( + sources: Getter | Array>, + flush: "post" | "pre", + effect: + | ((values: T | Array, previousValues: T | Array) => void) + | VoidFunction, +): void { + const cleanupRoot = $effect.root(() => { + let stop = false; + runWatcher( + sources, + flush, + (values, previousValues) => { + if (stop) { + cleanupRoot(); + return; + } + + // Since `lazy` is `true`, `previousValues` is always defined. + const cleanup = effect(values, previousValues as T | Array); + stop = true; + return cleanup; + }, + // Running the effect immediately just once makes no sense at all. + // That's just `onMount` with extra steps. + { lazy: true }, + ); + }); + + $effect(() => { + return cleanupRoot; + }); +} + +export function watch>( + sources: { + [K in keyof T]: Getter; + }, + effect: + | (( + values: T, + previousValues: { + [K in keyof T]: T[K] | undefined; + }, + ) => void) + | VoidFunction, + options?: WatchOptions, +): void; + +export function watch( + source: Getter, + effect: ((value: T, previousValue: T | undefined) => void) | VoidFunction, + options?: WatchOptions, +): void; + +export function watch( + sources: Getter | Array>, + effect: + | (( + values: T | Array, + previousValues: T | undefined | Array, + ) => void) + | VoidFunction, + options?: WatchOptions, +): void { + runWatcher(sources, "post", effect, options); +} + +function watchPre>( + sources: { + [K in keyof T]: Getter; + }, + effect: + | (( + values: T, + previousValues: { + [K in keyof T]: T[K] | undefined; + }, + ) => void) + | VoidFunction, + options?: WatchOptions, +): void; + +function watchPre( + source: Getter, + effect: ((value: T, previousValue: T | undefined) => void) | VoidFunction, + options?: WatchOptions, +): void; + +function watchPre( + sources: Getter | Array>, + effect: + | (( + values: T | Array, + previousValues: T | undefined | Array, + ) => void) + | VoidFunction, + options?: WatchOptions, +): void { + runWatcher(sources, "pre", effect, options); +} + +watch.pre = watchPre; + +export function watchOnce>( + sources: { + [K in keyof T]: Getter; + }, + effect: ((values: T, previousValues: T) => void) | VoidFunction, +): void; + +export function watchOnce( + source: Getter, + effect: ((value: T, previousValue: T) => void) | VoidFunction, +): void; + +export function watchOnce( + source: Getter | Array>, + effect: + | ((value: T | Array, previousValue: T | Array) => void) + | VoidFunction, +): void { + runWatcherOnce(source, "post", effect); +} + +function watchOncePre>( + sources: { + [K in keyof T]: Getter; + }, + effect: ((values: T, previousValues: T) => void) | VoidFunction, +): void; + +function watchOncePre( + source: Getter, + effect: ((value: T, previousValue: T) => void) | VoidFunction, +): void; + +function watchOncePre( + source: Getter | Array>, + effect: + | ((value: T | Array, previousValue: T | Array) => void) + | VoidFunction, +): void { + runWatcherOnce(source, "pre", effect); +} + +watchOnce.pre = watchOncePre; diff --git a/packages/floating-ui-svelte/src/safe-polygon.ts b/packages/floating-ui-svelte/src/safe-polygon.ts new file mode 100644 index 00000000..86d8f15d --- /dev/null +++ b/packages/floating-ui-svelte/src/safe-polygon.ts @@ -0,0 +1,412 @@ +import { isElement } from "@floating-ui/utils/dom"; +import type { Rect, Side } from "./types.js"; +import { contains, getTarget } from "./internal/dom.js"; +import { getChildren } from "./internal/get-children.js"; +import type { HandleCloseFn } from "./hooks/use-hover.svelte.js"; + +type Point = [number, number]; +type Polygon = Point[]; + +function isPointInPolygon(point: Point, polygon: Polygon) { + const [x, y] = point; + let isInside = false; + const length = polygon.length; + for (let i = 0, j = length - 1; i < length; j = i++) { + const [xi, yi] = polygon[i] || [0, 0]; + const [xj, yj] = polygon[j] || [0, 0]; + const intersect = + yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (intersect) { + isInside = !isInside; + } + } + return isInside; +} + +function isInside(point: Point, rect: Rect) { + return ( + point[0] >= rect.x && + point[0] <= rect.x + rect.width && + point[1] >= rect.y && + point[1] <= rect.y + rect.height + ); +} + +interface SafePolygonOptions { + buffer?: number; + blockPointerEvents?: boolean; + requireIntent?: boolean; +} + +interface IntentState { + velocities: Array<{ x: number; y: number; timestamp: number }>; + maxSamples: number; +} + +/** + * Generates a safe polygon area that the user can traverse without closing the + * floating element once leaving the reference element. + * @see https://floating-ui.com/docs/useHover#safepolygon + */ +function safePolygon(options: SafePolygonOptions = {}) { + const { + buffer = 0.5, + blockPointerEvents = false, + requireIntent = true, + } = options; + + const fn: HandleCloseFn = (context) => { + let timeoutId: number; + let hasLanded = false; + + const intentState: IntentState = { + velocities: [], + maxSamples: 5, + }; + + function updateIntentState(x: number, y: number): boolean { + const now = performance.now(); + const velocities = intentState.velocities; + + if (velocities.length >= intentState.maxSamples) { + velocities.shift(); + } + + velocities.push({ x, y, timestamp: now }); + + if (velocities.length < 3) return false; + + // Calculate average velocity over last few samples + const avgVelocity = + velocities.reduce((acc, curr, i, arr) => { + if (i === 0) return acc; + const prev = arr[i - 1]; + const dt = curr.timestamp - prev.timestamp; + const dx = curr.x - prev.x; + const dy = curr.y - prev.y; + return acc + Math.sqrt(dx * dx + dy * dy) / dt; + }, 0) / + (velocities.length - 1); + + return avgVelocity < 0.03; + } + return function onMouseMove(event: MouseEvent) { + function close() { + window.clearTimeout(timeoutId); + context.onClose(); + } + + window.clearTimeout(timeoutId); + + if ( + !context.elements.domReference || + !context.elements.floating || + context.placement == null || + context.x == null || + context.y == null + ) { + return; + } + + const { clientX, clientY } = event; + const clientPoint: Point = [clientX, clientY]; + const target = getTarget(event) as Element | null; + const isLeave = event.type === "mouseleave"; + const isOverFloatingEl = contains(context.elements.floating, target); + const isOverReferenceEl = contains(context.elements.domReference, target); + const refRect = context.elements.domReference.getBoundingClientRect(); + const rect = context.elements.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); + const isFloatingWider = rect.width > refRect.width; + const isFloatingTaller = rect.height > refRect.height; + const left = (isFloatingWider ? refRect : rect).left; + const right = (isFloatingWider ? refRect : rect).right; + const top = (isFloatingTaller ? refRect : rect).top; + const bottom = (isFloatingTaller ? refRect : rect).bottom; + + if (isOverFloatingEl) { + hasLanded = true; + intentState.velocities = []; + + if (!isLeave) return; + } + + if (isOverReferenceEl) { + hasLanded = false; + } + + if (isOverReferenceEl && !isLeave) { + hasLanded = true; + return; + } + + // Prevent overlapping floating element from being stuck in an open-close + // loop: https://github.com/floating-ui/floating-ui/issues/1910 + if ( + isLeave && + isElement(event.relatedTarget) && + contains(context.elements.floating, event.relatedTarget) + ) { + return; + } + + // If any nested child is open, abort. + + if ( + context.tree && + getChildren(context.tree.nodes, context.nodeId).some( + ({ context }) => context?.open, + ) + ) { + return; + } + + // If the pointer is leaving from the opposite side, the "buffer" logic + // creates a point where the floating element remains open, but should be + // 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) + ) { + return close(); + } + + // Ignore when the cursor is within the rectangular trough between the + // two elements. Since the triangle is created from the cursor point, + // which can start beyond the ref element's edge, traversing back and + // forth from the ref to the floating element can cause it to close. This + // ensures it always remains open in that case. + let rectPoly: Point[] = []; + + switch (side) { + case "top": + rectPoly = [ + [left, refRect.top + 1], + [left, rect.bottom - 1], + [right, rect.bottom - 1], + [right, refRect.top + 1], + ]; + break; + case "bottom": + rectPoly = [ + [left, rect.top + 1], + [left, refRect.bottom - 1], + [right, refRect.bottom - 1], + [right, rect.top + 1], + ]; + break; + case "left": + rectPoly = [ + [rect.right - 1, bottom], + [rect.right - 1, top], + [refRect.left + 1, top], + [refRect.left + 1, bottom], + ]; + break; + case "right": + rectPoly = [ + [refRect.right - 1, bottom], + [refRect.right - 1, top], + [rect.left + 1, top], + [rect.left + 1, bottom], + ]; + break; + } + + function getPolygon([x, y]: Point): Array { + switch (side) { + case "top": { + const cursorPointOne: Point = [ + isFloatingWider + ? x + buffer / 2 + : cursorLeaveFromRight + ? x + buffer * 4 + : x - buffer * 4, + y + buffer + 1, + ]; + const cursorPointTwo: Point = [ + isFloatingWider + ? x - buffer / 2 + : cursorLeaveFromRight + ? x + buffer * 4 + : x - buffer * 4, + y + buffer + 1, + ]; + const commonPoints: [Point, Point] = [ + [ + rect.left, + cursorLeaveFromRight + ? rect.bottom - buffer + : isFloatingWider + ? rect.bottom - buffer + : rect.top, + ], + [ + rect.right, + cursorLeaveFromRight + ? isFloatingWider + ? rect.bottom - buffer + : rect.top + : rect.bottom - buffer, + ], + ]; + + return [cursorPointOne, cursorPointTwo, ...commonPoints]; + } + case "bottom": { + const cursorPointOne: Point = [ + isFloatingWider + ? x + buffer / 2 + : cursorLeaveFromRight + ? x + buffer * 4 + : x - buffer * 4, + y - buffer, + ]; + const cursorPointTwo: Point = [ + isFloatingWider + ? x - buffer / 2 + : cursorLeaveFromRight + ? x + buffer * 4 + : x - buffer * 4, + y - buffer, + ]; + const commonPoints: [Point, Point] = [ + [ + rect.left, + cursorLeaveFromRight + ? rect.top + buffer + : isFloatingWider + ? rect.top + buffer + : rect.bottom, + ], + [ + rect.right, + cursorLeaveFromRight + ? isFloatingWider + ? rect.top + buffer + : rect.bottom + : rect.top + buffer, + ], + ]; + + return [cursorPointOne, cursorPointTwo, ...commonPoints]; + } + case "left": { + const cursorPointOne: Point = [ + x + buffer + 1, + isFloatingTaller + ? y + buffer / 2 + : cursorLeaveFromBottom + ? y + buffer * 4 + : y - buffer * 4, + ]; + const cursorPointTwo: Point = [ + x + buffer + 1, + isFloatingTaller + ? y - buffer / 2 + : cursorLeaveFromBottom + ? y + buffer * 4 + : y - buffer * 4, + ]; + const commonPoints: [Point, Point] = [ + [ + cursorLeaveFromBottom + ? rect.right - buffer + : isFloatingTaller + ? rect.right - buffer + : rect.left, + rect.top, + ], + [ + cursorLeaveFromBottom + ? isFloatingTaller + ? rect.right - buffer + : rect.left + : rect.right - buffer, + rect.bottom, + ], + ]; + + return [...commonPoints, cursorPointOne, cursorPointTwo]; + } + case "right": { + const cursorPointOne: Point = [ + x - buffer, + isFloatingTaller + ? y + buffer / 2 + : cursorLeaveFromBottom + ? y + buffer * 4 + : y - buffer * 4, + ]; + const cursorPointTwo: Point = [ + x - buffer, + isFloatingTaller + ? y - buffer / 2 + : cursorLeaveFromBottom + ? y + buffer * 4 + : y - buffer * 4, + ]; + const commonPoints: [Point, Point] = [ + [ + cursorLeaveFromBottom + ? rect.left + buffer + : isFloatingTaller + ? rect.left + buffer + : rect.right, + rect.top, + ], + [ + cursorLeaveFromBottom + ? isFloatingTaller + ? rect.left + buffer + : rect.right + : rect.left + buffer, + rect.bottom, + ], + ]; + + return [cursorPointOne, cursorPointTwo, ...commonPoints]; + } + } + } + + if (isPointInPolygon([clientX, clientY], rectPoly)) { + return; + } + + if (hasLanded && !isOverReferenceRect) { + return close(); + } + + if (!isLeave && requireIntent && updateIntentState(clientX, clientY)) { + return close(); + } + + if ( + !isPointInPolygon( + [clientX, clientY], + getPolygon([context.x, context.y]), + ) + ) { + close(); + } else if (!hasLanded && requireIntent) { + timeoutId = window.setTimeout(close, 40); + } + }; + }; + + fn.__options = { + blockPointerEvents, + }; + + return fn; +} + +export type { SafePolygonOptions }; +export { safePolygon }; diff --git a/packages/floating-ui-svelte/src/types.ts b/packages/floating-ui-svelte/src/types.ts index 4b19995d..f72e1db1 100644 --- a/packages/floating-ui-svelte/src/types.ts +++ b/packages/floating-ui-svelte/src/types.ts @@ -1,3 +1,6 @@ +import type { Axis, Length, Side, VirtualElement } from "@floating-ui/dom"; +import type { FloatingContextData } from "./hooks/use-floating-context.svelte.js"; + type OpenChangeReason = | "outside-press" | "escape-key" @@ -6,7 +9,112 @@ type OpenChangeReason = | "click" | "hover" | "focus" + | "focus-out" | "list-navigation" | "safe-polygon"; -export type { OpenChangeReason }; +type ReferenceType = Element | VirtualElement; + +type NarrowedElement = T extends Element ? T : Element; + +type Boxed = { current: T }; + +type Coords = { [key in Axis]: number }; +type Dimensions = { [key in Length]: number }; +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; + // 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; + + floatingContext?: FloatingContextData; + + /** @deprecated use `onTypingChange` prop in `useTypeahead` */ + typing?: boolean; + + /** + * Arbitrary data produced and consumed by other hooks. + */ + [key: string]: unknown; +} + +type OnOpenChange = ( + open: boolean, + event?: Event, + reason?: OpenChangeReason, +) => void; + +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?: FloatingContextData; +} + +interface FloatingTreeType { + nodes: FloatingNodeType[]; + events: FloatingEvents; + addNode(node: FloatingNodeType): void; + removeNode(node: FloatingNodeType): void; +} + +type WhileElementsMounted = ( + reference: RT, + floating: HTMLElement, + update: () => void, +) => () => void; + +type Getter = () => T; +type MaybeGetter = T | Getter; + +interface WithRef { + /** + * A bindable reference to the element. + */ + ref?: T | null; +} + +export type { + OpenChangeReason, + FloatingEvents, + ContextData, + FloatingNodeType, + FloatingTreeType, + ReferenceType, + NarrowedElement, + OnOpenChange, + Getter, + MaybeGetter, + WhileElementsMounted, + WithRef, + Boxed, + Rect, + Coords, + Dimensions, + Side, +}; 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/components/floating-arrow.ts b/packages/floating-ui-svelte/test/components/floating-arrow.ts deleted file mode 100644 index 053b210c..00000000 --- a/packages/floating-ui-svelte/test/components/floating-arrow.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 { withRunes } from "../internal/with-runes.svelte.js"; - -describe("FloatingArrow", () => { - it( - "renders the component to default props", - withRunes(() => { - const arrowRef = document.createElement("div"); - const floating = useFloating(); - render(FloatingArrow, { - props: { ref: arrowRef, context: floating.context }, - }); - const component = screen.getByTestId("floating-arrow"); - expect(component).toBeInTheDocument(); - }), - ); - - it( - "renders position based on context placement", - withRunes(() => { - const arrowRef = document.createElement("div"); - const floating = useFloating({ placement: "left" }); - render(FloatingArrow, { - props: { - ref: arrowRef, - context: floating.context, - width: 20, - height: 20, - }, - }); - const component = screen.getByTestId("floating-arrow"); - expect(component.style.left).toBe("calc(100% - 0px)"); - }), - ); - - it( - "renders with a custom width and height", - withRunes(() => { - const arrowRef = document.createElement("div"); - const floating = useFloating(); - render(FloatingArrow, { - props: { - ref: arrowRef, - context: floating.context, - width: 20, - height: 20, - }, - }); - const component = screen.getByTestId("floating-arrow"); - expect(component.getAttribute("width")).equals("20"); - expect(component.getAttribute("height")).equals("20"); - }), - ); - - it( - "renders with a custom transform", - withRunes(() => { - const arrowRef = document.createElement("div"); - const floating = useFloating(); - render(FloatingArrow, { - props: { ref: arrowRef, context: floating.context, transform: "123px" }, - }); - const component = screen.getByTestId("floating-arrow"); - expect(component.style.transform).toContain("123px"); - }), - ); - - it( - "renders with a custom fill", - withRunes(() => { - const arrowRef = document.createElement("div"); - const floating = useFloating(); - const testFillColor = "green"; - render(FloatingArrow, { - props: { - ref: arrowRef, - context: floating.context, - fill: testFillColor, - }, - }); - const component = screen.getByTestId("floating-arrow"); - expect(component.style.fill).toContain(testFillColor); - }), - ); -}); diff --git a/packages/floating-ui-svelte/test/hooks/use-dismiss.ts b/packages/floating-ui-svelte/test/hooks/use-dismiss.ts deleted file mode 100644 index a4df043d..00000000 --- a/packages/floating-ui-svelte/test/hooks/use-dismiss.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/svelte"; -import { describe, expect, it, vi } from "vitest"; -import App from "./wrapper-components/use-dismiss.svelte"; - -describe("useDismiss", () => { - describe("default", () => { - it("does dismiss on outside pointerdown", async () => { - render(App, { open: true }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(document); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does dismiss on `Escape` key press", async () => { - render(App, { open: true }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.keyDown(document, { key: "Escape" }); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - }); - - describe("enabled", () => { - it("does dismiss when set to `true`", async () => { - render(App, { open: true, enabled: true }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(document); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does not dismiss when set to `false`", async () => { - render(App, { open: true, enabled: false }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(document); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - }); - }); - - describe("escapeKey", () => { - it("does dismiss on `Escape` key press when set to `true`", async () => { - render(App, { open: true, escapeKey: true }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.keyDown(document, { key: "Escape" }); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does not dismiss on `Escape` key press when set to `false`", async () => { - render(App, { open: true, escapeKey: false }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.keyDown(document, { key: "Escape" }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - }); - }); - - describe("outsidePress", () => { - describe("boolean", () => { - it("does dismiss on outside press when set to `true`", async () => { - render(App, { open: true, outsidePress: true }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(document); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does not dismiss on outside press when set to `false`", async () => { - render(App, { open: true, outsidePress: false }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(document); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - }); - }); - describe("function", () => { - it("does dismiss on outside press when the function returns `true`", async () => { - const outsidePress = vi.fn(() => true); - render(App, { open: true, outsidePress }); - - expect(outsidePress).not.toHaveBeenCalled(); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(document); - - expect(outsidePress).toHaveBeenCalledOnce(); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does not dismiss on outside press when the function returns `false`", async () => { - const outsidePress = vi.fn(() => false); - render(App, { open: true, outsidePress: outsidePress }); - - expect(outsidePress).not.toHaveBeenCalled(); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(document); - - expect(outsidePress).toHaveBeenCalledOnce(); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - }); - - it.skip("passes the corresponding event as argument", async () => { - const outsidePress = vi.fn(() => true); - render(App, { open: true, outsidePress }); - - expect(outsidePress).not.toHaveBeenCalled(); - - const event = new MouseEvent("pointerdown"); - - await fireEvent.pointerDown(document, event); - - expect(outsidePress.mock.calls.at(0)?.at(0)).toBe(event); - }); - }); - }); - - describe("outsidePressEvent", () => { - it("does dismiss on outside `pointerdown` event when set to `pointerdown`", async () => { - render(App, { - open: true, - outsidePress: true, - outsidePressEvent: "pointerdown", - }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(document); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does dismiss on outside `mousedown` event when set to `mousedown`", async () => { - render(App, { - open: true, - outsidePress: true, - outsidePressEvent: "mousedown", - }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.mouseDown(document); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does dismiss on outside `click` event when set to `click`", async () => { - render(App, { - open: true, - outsidePress: true, - outsidePressEvent: "click", - }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.click(document); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - }); - - describe("referencePress", () => { - it("does dismiss on reference press when set to `true`", async () => { - render(App, { open: true, referencePress: true }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(screen.getByTestId("reference")); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does not dismiss on reference press when set to `false`", async () => { - render(App, { open: true, referencePress: false }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(screen.getByTestId("reference")); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - }); - }); - - describe("referencePressEvent", () => { - it("does dismiss on reference `pointerdown` event when set to `pointerdown`", async () => { - render(App, { - open: true, - referencePress: true, - referencePressEvent: "pointerdown", - }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.pointerDown(screen.getByTestId("reference")); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does dismiss on reference `mousedown` event when set to `mousedown`", async () => { - render(App, { - open: true, - referencePress: true, - referencePressEvent: "mousedown", - }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.mouseDown(screen.getByTestId("reference")); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does dismiss on reference `click` event when set to `click`", async () => { - render(App, { - open: true, - referencePress: true, - referencePressEvent: "click", - }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.click(screen.getByTestId("reference")); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - }); - - describe("ancestorScroll", () => { - it("does dismiss on ancestor scroll when set to `true`", async () => { - render(App, { open: true, ancestorScroll: true }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.scroll(window); - - expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); - }); - - it("does not dismiss on ancestor scroll when set to `false`", async () => { - render(App, { open: true, ancestorScroll: false }); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - - await fireEvent.scroll(window); - - expect(screen.queryByTestId("floating")).toBeInTheDocument(); - }); - }); -}); 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 deleted file mode 100644 index 7174f591..00000000 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-click.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - - - -{#if open} -
-{/if} \ No newline at end of file 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 deleted file mode 100644 index a03f3759..00000000 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-dismiss.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - - - -{#if open} -
-{/if} \ No newline at end of file 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 deleted file mode 100644 index 0a531e0c..00000000 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-focus.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - - - -{#if open} -
-{/if} \ No newline at end of file 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 deleted file mode 100644 index 341e4e06..00000000 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-hover.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - -{#if showReference} - -{/if} - -{#if open} -
-{/if} \ No newline at end of file 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 deleted file mode 100644 index a0c879b5..00000000 --- a/packages/floating-ui-svelte/test/hooks/wrapper-components/use-role.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - - -{#if open} -
- {#each [1, 2, 3] as i} -
- {/each} -
-{/if} \ No newline at end of file diff --git a/packages/floating-ui-svelte/test/internal/setup.ts b/packages/floating-ui-svelte/test/internal/setup.ts deleted file mode 100644 index f149f27a..00000000 --- a/packages/floating-ui-svelte/test/internal/setup.ts +++ /dev/null @@ -1 +0,0 @@ -import "@testing-library/jest-dom/vitest"; diff --git a/packages/floating-ui-svelte/test/unit/components/floating-arrow.test.ts b/packages/floating-ui-svelte/test/unit/components/floating-arrow.test.ts new file mode 100644 index 00000000..c2cad593 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-arrow.test.ts @@ -0,0 +1,97 @@ +// import { render, screen } from "@testing-library/svelte"; +// import { describe, expect, it, vi } from "vitest"; +// import { withRunes } from "../../with-runes.svelte.js"; +// import { useFloating, FloatingArrow } from "../../../src/index.js"; + +import { describe } from "vitest"; + +describe.skip("FloatingArrow"); +// describe.skip("FloatingArrow", () => { +// vi.mock(import("svelte"), async (importOriginal) => { +// const actual = await importOriginal(); +// return { +// ...actual, +// getContext: vi.fn().mockReturnValue(null), +// }; +// }); +// it( +// "renders the component to default props", +// withRunes(() => { +// const arrowRef = document.createElement("div"); +// const floating = useFloating(); +// render(FloatingArrow, { +// props: { ref: arrowRef, context: floating.context }, +// }); +// const component = screen.getByTestId("floating-arrow"); +// expect(component).toBeInTheDocument(); +// }), +// ); + +// it( +// "renders position based on context placement", +// withRunes(() => { +// const arrowRef = document.createElement("div"); +// const floating = useFloating({ placement: "left" }); +// render(FloatingArrow, { +// props: { +// ref: arrowRef, +// context: floating.context, +// width: 20, +// height: 20, +// }, +// }); +// const component = screen.getByTestId("floating-arrow"); +// expect(component.style.left).toBe("calc(100% - 0px)"); +// }), +// ); + +// it( +// "renders with a custom width and height", +// withRunes(() => { +// const arrowRef = document.createElement("div"); +// const floating = useFloating(); +// render(FloatingArrow, { +// props: { +// ref: arrowRef, +// context: floating.context, +// width: 20, +// height: 20, +// }, +// }); +// const component = screen.getByTestId("floating-arrow"); +// expect(component.getAttribute("width")).equals("20"); +// expect(component.getAttribute("height")).equals("20"); +// }), +// ); + +// it( +// "renders with a custom transform", +// withRunes(() => { +// const arrowRef = document.createElement("div"); +// const floating = useFloating(); +// render(FloatingArrow, { +// props: { ref: arrowRef, context: floating.context, transform: "123px" }, +// }); +// const component = screen.getByTestId("floating-arrow"); +// expect(component.style.transform).toContain("123px"); +// }), +// ); + +// it( +// "renders with a custom fill", +// withRunes(() => { +// const arrowRef = document.createElement("div"); +// const floating = useFloating(); +// const testFillColor = "green"; +// render(FloatingArrow, { +// props: { +// ref: arrowRef, +// context: floating.context, +// fill: testFillColor, +// }, +// }); +// const component = screen.getByTestId("floating-arrow"); +// expect(component.style.fill).toContain(testFillColor); +// }), +// ); +// }); diff --git a/packages/floating-ui-svelte/test/unit/components/floating-delay-group/components/main.svelte b/packages/floating-ui-svelte/test/unit/components/floating-delay-group/components/main.svelte new file mode 100644 index 00000000..37d552a2 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-delay-group/components/main.svelte @@ -0,0 +1,46 @@ + + + + + {#snippet reference(ref, props)} + + {/snippet} + + + {#snippet reference(ref, props)} + + {/snippet} + + + {#snippet reference(ref, props)} + + {/snippet} + + diff --git a/packages/floating-ui-svelte/test/unit/components/floating-delay-group/components/tooltip.svelte b/packages/floating-ui-svelte/test/unit/components/floating-delay-group/components/tooltip.svelte new file mode 100644 index 00000000..ed567e4a --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-delay-group/components/tooltip.svelte @@ -0,0 +1,62 @@ + + +{@render reference(ref, ints.getReferenceProps())} + +{#if open} +
+ {label} +
+{/if} diff --git a/packages/floating-ui-svelte/test/unit/components/floating-delay-group/floating-delay-group.test.ts b/packages/floating-ui-svelte/test/unit/components/floating-delay-group/floating-delay-group.test.ts new file mode 100644 index 00000000..531e640c --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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/unit/components/floating-focus-manager/components/combobox.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/combobox.svelte new file mode 100644 index 00000000..04bc4171 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/combobox.svelte @@ -0,0 +1,40 @@ + + + + (open = true)} /> + + + +{#if open} + +
+
+
+{/if} 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 new file mode 100644 index 00000000..c908d119 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/connected-drawer.svelte @@ -0,0 +1,26 @@ + + + +
+ +
+
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 new file mode 100644 index 00000000..0871262d --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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/unit/components/floating-focus-manager/components/dialog-fallback-ref.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/dialog-fallback-ref.svelte new file mode 100644 index 00000000..41e8e869 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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/unit/components/floating-focus-manager/components/dialog-non-focusable-ref.svelte b/packages/floating-ui-svelte/test/unit/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/unit/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/unit/components/floating-focus-manager/components/dialog.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/dialog.svelte new file mode 100644 index 00000000..0000782f --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/dialog.svelte @@ -0,0 +1,66 @@ + + + + {@render reference(referenceRef, ints.getReferenceProps())} + + {#if open} + +
+ {@render content(close)} +
+
+ {/if} +
+
diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-fallback.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-fallback.svelte new file mode 100644 index 00000000..4b242995 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-fallback.svelte @@ -0,0 +1,14 @@ + + + + + +
+
+
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 new file mode 100644 index 00000000..efe01941 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/floating-wrapper.svelte @@ -0,0 +1,35 @@ + + + + +{#if open} + +
+
+
+
+{/if} diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/hover.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/hover.svelte new file mode 100644 index 00000000..645b0f3c --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/hover.svelte @@ -0,0 +1,34 @@ + + + +{#if open} + +
+
+
+{/if} + 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 new file mode 100644 index 00000000..3194d588 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/keep-mounted.svelte @@ -0,0 +1,34 @@ + + + + +
+ +
+
+ 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 new file mode 100644 index 00000000..e057c466 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/main.svelte @@ -0,0 +1,75 @@ + + + + + + + + + + +{#if open} + +
+ + + + {#if renderInput} + + + {/if} +
+
+{/if} + +
x
diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/mixed-mod-dialog.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/mixed-mod-dialog.svelte new file mode 100644 index 00000000..3ded37ed --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/mixed-mod-dialog.svelte @@ -0,0 +1,73 @@ + + + + {@render reference?.(ref, ints.getReferenceProps())} + {#if open} + + +
+ {@render content?.(() => (controlledOpen = false))} +
+
+
+ {/if} + {@render sideChildren?.()} +
diff --git a/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/mixed-mod-main.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/mixed-mod-main.svelte new file mode 100644 index 00000000..62a203a4 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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/unit/components/floating-focus-manager/components/mixed-mod-nested-dialog.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/mixed-mod-nested-dialog.svelte new file mode 100644 index 00000000..0ed4d412 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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/unit/components/floating-focus-manager/components/modal-combobox.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/modal-combobox.svelte new file mode 100644 index 00000000..dbde5418 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/modal-combobox.svelte @@ -0,0 +1,38 @@ + + + + +{#if open} + +
+ +
+
+{/if} + + + {/snippet} + {#snippet content(close)} + + {#snippet reference(nestedRef, nestedRefProps)} + + {/snippet} + + {#snippet content(nestedClose)} + + {/snippet} + + + {/snippet} + 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 new file mode 100644 index 00000000..36a34c31 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/non-modal-floating-portal.svelte @@ -0,0 +1,45 @@ + + + + + +{#if open} + + +
+ + +
+
+
+{/if} + + 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 new file mode 100644 index 00000000..e3fe0729 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/outside-nodes.svelte @@ -0,0 +1,37 @@ + + + (open = !open)} /> +
+
+ + +
+{#if open} + +
+
+
+{/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 new file mode 100644 index 00000000..0ff2e55f --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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/unit/components/floating-focus-manager/components/toggle-disabled.svelte b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/toggle-disabled.svelte new file mode 100644 index 00000000..3dbd98d6 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/toggle-disabled.svelte @@ -0,0 +1,42 @@ + + + + + +{#if open} + +
+
+
+{/if} 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 new file mode 100644 index 00000000..cff00391 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/trapped-combobox.svelte @@ -0,0 +1,45 @@ + + +
+ + + {#if open} + +
+ + +
+
+ {/if} +
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 new file mode 100644 index 00000000..119dc68b --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/components/untrapped-combobox.svelte @@ -0,0 +1,52 @@ + + +
+ + + {#if open} + + +
+ + +
+
+
+ {/if} + +
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 new file mode 100644 index 00000000..7de2fd64 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-focus-manager/floating-focus-manager.test.ts @@ -0,0 +1,820 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +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 { 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"; +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 Navigation from "../../../visual/components/navigation/main.svelte"; +import Drawer from "../../../visual/components/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"; +import ModalCombobox from "./components/modal-combobox.svelte"; +import Hover from "./components/hover.svelte"; +import Menubar from "../../../visual/components/menubar/main.svelte"; + +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("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 sleep(20); + + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + + await waitFor(() => + 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()); + + 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("three")).toHaveFocus()); + + 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()); + }); + + it("supports keepMounted behavior", async () => { + render(KeepMounted); + + expect(screen.getByTestId("floating")).not.toHaveFocus(); + + await fireEvent.click(screen.getByTestId("reference")); + + await waitFor(() => expect(screen.getByTestId("child")).toHaveFocus()); + + await userEvent.tab(); + + await waitFor(() => expect(screen.getByTestId("after")).toHaveFocus()); + + await userEvent.tab({ shift: true }); + + await fireEvent.click(screen.getByTestId("reference")); + + await waitFor(() => expect(screen.getByTestId("child")).toHaveFocus()); + + await userEvent.keyboard(testKbd.ESCAPE); + + 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 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(); + + await waitFor(() => expect(screen.getByTestId("inside")).toHaveFocus()); + + await userEvent.tab(); + + await waitFor(() => expect(screen.getByTestId("last")).toHaveFocus()); + }); + + it("handles shift + tab", async () => { + render(NonModalFloatingPortal); + + await userEvent.click(screen.getByTestId("reference")); + await waitFor(() => + expect(screen.queryByTestId("floating")).toBeInTheDocument(), + ); + await sleep(20); + await userEvent.tab({ shift: true }); + + expect(screen.queryByTestId("floating")).toBeInTheDocument(); + + await userEvent.tab({ shift: true }); + + expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); + }); +}); + +describe("Navigation", () => { + it("does not focus reference when hovering it", async () => { + render(Navigation); + await userEvent.hover(screen.getByText("Product")); + await userEvent.unhover(screen.getByText("Product")); + expect(screen.getByText("Product")).not.toHaveFocus(); + }); + + it("returns focus to reference when floating element was opened by hover but is closed by esc key", async () => { + render(Navigation); + await userEvent.hover(screen.getByText("Product")); + await userEvent.keyboard(testKbd.ESCAPE); + expect(screen.getByText("Product")).toHaveFocus(); + }); + + it("returns focus to reference when floating element was opened by hover but is closed by an explicit close action", async () => { + render(Navigation); + await userEvent.hover(screen.getByText("Product")); + + await userEvent.click(screen.getByText("Close").parentElement!); + await userEvent.keyboard(testKbd.TAB); + await waitFor(() => expect(screen.getByText("Close")).toHaveFocus()); + await userEvent.keyboard(testKbd.ENTER); + await sleep(20); + + 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")); + await waitFor(() => + 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("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()); + }); +}); + +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("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()); +}); + +it("closes the floating element upon tabbing out of a modal combobox", async () => { + render(ModalCombobox); + + await userEvent.click(screen.getByTestId("input")); + await waitFor(() => expect(screen.getByTestId("input")).toHaveFocus()); + await userEvent.tab(); + await waitFor(() => expect(screen.getByTestId("after")).toHaveFocus()); +}); + +it("does not return focus to the reference when floating element is triggered by hover", async () => { + render(Hover); + + const reference = screen.getByTestId("reference"); + reference.focus(); + await waitFor(() => expect(reference).toHaveFocus()); + + await userEvent.hover(reference); + + await waitFor(() => expect(screen.getByTestId("floating")).toHaveFocus()); + + await userEvent.unhover(screen.getByTestId("floating")); + + await waitFor(() => + expect(screen.getByTestId("reference")).not.toHaveFocus(), + ); +}); + +it("uses aria-hidden instead of inert on outside nodes if opened with hover and modal=true", async () => { + render(Hover); + + await userEvent.hover(screen.getByTestId("reference")); + + await waitFor(() => + expect(screen.getByText("outside")).not.toHaveAttribute("inert"), + ); + await waitFor(() => + expect(screen.getByText("outside")).toHaveAttribute("aria-hidden", "true"), + ); +}); + +it("returns focus to the appropriate trigger when navigating submenus with keyboard", async () => { + render(Menubar); + + await userEvent.click(screen.getByText("Edit")); + await sleep(20); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await waitFor(() => expect(screen.getByText("Submenu")).toHaveFocus()); + await userEvent.keyboard(testKbd.ARROW_RIGHT); + await sleep(20); + await waitFor(() => expect(screen.getByText("Second level")).toHaveFocus()); + await userEvent.keyboard(testKbd.ARROW_LEFT); + await sleep(20); + await waitFor(() => expect(screen.getByText("Submenu")).toHaveFocus()); +}); 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..60e97eaf --- /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..b4e39986 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/components/floating-portal/floating-portal.test.ts @@ -0,0 +1,36 @@ +import { + act, + 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/hooks/use-click.ts b/packages/floating-ui-svelte/test/unit/hooks/use-click.test.ts similarity index 96% rename from packages/floating-ui-svelte/test/hooks/use-click.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-click.test.ts index c3820898..0d71ae74 100644 --- a/packages/floating-ui-svelte/test/hooks/use-click.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-click.test.ts @@ -177,7 +177,7 @@ describe("useClick", () => { expect(screen.queryByTestId("floating")).toBeInTheDocument(); }); - it("when applied to a typable reference does not return a `Space` key event handler", async () => { + it("when applied to a typeable reference does not return a `Space` key event handler", async () => { render(App, { element: "input" }); await fireEvent.keyDown(screen.getByTestId("reference"), { key: " " }); @@ -186,7 +186,7 @@ describe("useClick", () => { expect(screen.queryByTestId("floating")).not.toBeInTheDocument(); }); - it("when applied to a typable reference does not return a `Enter` key event handler", async () => { + it("when applied to a typeable reference does not return a `Enter` key event handler", async () => { render(App, { element: "input" }); await fireEvent.keyDown(screen.getByTestId("reference"), { diff --git a/packages/floating-ui-svelte/test/unit/hooks/use-client-point.test.ts b/packages/floating-ui-svelte/test/unit/hooks/use-client-point.test.ts new file mode 100644 index 00000000..fcf9cb46 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/use-client-point.test.ts @@ -0,0 +1,224 @@ +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 "../../utils.js"; + +function expectLocation({ x, y }: Coords) { + expect(Number(screen.getByTestId("x")?.textContent)).toBe(x); + expect(Number(screen.getByTestId("y")?.textContent)).toBe(y); + expect(Number(screen.getByTestId("width")?.textContent)).toBe(0); + expect(Number(screen.getByTestId("height")?.textContent)).toBe(0); +} + +it("renders at explicit client point and can be updated", async () => { + render(UseClientPoint, { x: 0, y: 0 }); + + await fireEvent.click(screen.getByTestId("toggle-open")); + + expectLocation({ x: 0, y: 0 }); + + await fireEvent.click(screen.getByTestId("set-point")); + await sleep(10); + + expectLocation({ x: 1000, y: 1000 }); +}); + +it("renders at mouse event coords", async () => { + render(UseClientPoint); + + await fireEvent( + screen.getByTestId("reference"), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 500, + clientY: 500, + }), + ); + + expectLocation({ x: 500, y: 500 }); + + await fireEvent( + screen.getByTestId("reference"), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 1000, + clientY: 1000, + }), + ); + + expectLocation({ x: 1000, y: 1000 }); + + // Window listener isn't registered unless the floating element is open. + await fireEvent( + window, + new MouseEvent("mousemove", { + bubbles: true, + clientX: 700, + clientY: 700, + }), + ); + + 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 () => { + render(UseClientPoint, { x: 0, y: 0 }); + + await fireEvent( + screen.getByTestId("reference"), + new MouseEvent("mousemove", { + bubbles: true, + clientX: 500, + clientY: 500, + }), + ); + + expectLocation({ x: 0, y: 0 }); +}); + +it("cleans up window listener when closing or disabling", 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.click(screen.getByTestId("toggle-open")); + + await fireEvent( + document.body, + new MouseEvent("mousemove", { + bubbles: true, + clientX: 0, + clientY: 0, + }), + ); + + expectLocation({ x: 500, y: 500 }); + + await fireEvent.click(screen.getByTestId("toggle-open")); + + await fireEvent( + document.body, + new MouseEvent("mousemove", { + bubbles: true, + clientX: 500, + clientY: 500, + }), + ); + + expectLocation({ x: 500, y: 500 }); + + await fireEvent.click(screen.getByTestId("toggle-enabled")); + + await fireEvent( + document.body, + new MouseEvent("mousemove", { + bubbles: true, + clientX: 0, + clientY: 0, + }), + ); + + 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 }); +}); 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 new file mode 100644 index 00000000..465d9c68 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/use-dismiss.test.ts @@ -0,0 +1,485 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import Dismiss from "./wrapper-components/use-dismiss/dismiss.svelte"; +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 () => { + render(Dismiss); + await fireEvent.keyDown(document.body, { key: "Escape" }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("does not dismiss with escape key if IME is active", async () => { + const onClose = vi.fn(); + + render(Dismiss, { onClose, escapeKey: true }); + + const textbox = screen.getByRole("textbox"); + + await fireEvent.focus(textbox); + + // Simulate behavior when "あ" (Japanese) is entered and Esc is pressed for IME + // cancellation. + await fireEvent.change(textbox, { target: { value: "あ" } }); + await fireEvent.compositionStart(textbox); + await fireEvent.keyDown(textbox, { key: "Escape" }); + await fireEvent.compositionEnd(textbox); + + // Wait for the compositionend timeout tick due to Safari + await sleep(); + + expect(onClose).toHaveBeenCalledTimes(0); + + await fireEvent.keyDown(textbox, { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("dismisses with outside pointer press", async () => { + render(Dismiss); + await userEvent.click(document.body); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("dismisses with reference press", async () => { + render(Dismiss, { referencePress: true }); + await userEvent.click(screen.getByRole("button")); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("dismisses with native click", async () => { + render(Dismiss, { referencePress: true }); + await fireEvent.click(screen.getByRole("button")); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("dismisses with ancestor scroll", async () => { + render(Dismiss, { ancestorScroll: true }); + await fireEvent.scroll(window); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("respects outside press function guard", async () => { + render(Dismiss, { outsidePress: () => false }); + await userEvent.click(document.body); + expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + }); + + it("ignores outside press for third party elements", async () => { + render(ThirdParty); + await act(async () => {}); + + const thirdParty = document.createElement("div"); + thirdParty.setAttribute("data-testid", "third-party"); + document.body.appendChild(thirdParty); + await userEvent.click(thirdParty); + expect(screen.queryByRole("dialog")).toBeInTheDocument(); + thirdParty.remove(); + }); + + describe("does not ignore outside press for nested floating elements", () => { + it("respects when both are modals", async () => { + render(DismissNestedPopovers, { modal: [true, true] }); + await act(async () => {}); + + const popover1 = screen.getByTestId("popover-1"); + const popover2 = screen.getByTestId("popover-2"); + await userEvent.click(popover2); + expect(popover1).toBeInTheDocument(); + expect(popover2).toBeInTheDocument(); + await userEvent.click(popover1); + expect(popover2).not.toBeInTheDocument(); + }); + + it("respects when outside is modal and inside is not", async () => { + render(DismissNestedPopovers, { modal: [true, false] }); + await act(async () => {}); + + const popover1 = screen.getByTestId("popover-1"); + const popover2 = screen.getByTestId("popover-2"); + + await userEvent.click(popover2); + expect(popover1).toBeInTheDocument(); + expect(popover2).toBeInTheDocument(); + await userEvent.click(popover1); + expect(popover2).not.toBeInTheDocument(); + }); + + it("respects when inside is modal and outside is not", async () => { + render(DismissNestedPopovers, { modal: [false, true] }); + await act(async () => {}); + + const popover1 = screen.getByTestId("popover-1"); + const popover2 = screen.getByTestId("popover-2"); + await userEvent.click(popover2); + expect(popover1).toBeInTheDocument(); + expect(popover2).toBeInTheDocument(); + await userEvent.click(popover1); + expect(popover2).not.toBeInTheDocument(); + }); + + it("respects when neither are modals", async () => { + render(DismissNestedPopovers, { modal: null }); + await act(async () => {}); + + const popover1 = screen.getByTestId("popover-1"); + const popover2 = screen.getByTestId("popover-2"); + await userEvent.click(popover2); + expect(popover1).toBeInTheDocument(); + expect(popover2).toBeInTheDocument(); + await userEvent.click(popover1); + expect(popover2).not.toBeInTheDocument(); + }); + }); +}); + +describe("false", () => { + it("dismisses with escape key", async () => { + render(Dismiss, { escapeKey: false }); + await fireEvent.keyDown(document.body, { key: "Escape" }); + expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + }); + + it("dismisses with outside press", async () => { + render(Dismiss, { outsidePress: false }); + await userEvent.click(document.body); + expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + }); + + it("dismisses with reference pointer down", async () => { + render(Dismiss, { referencePress: false }); + await userEvent.click(screen.getByRole("button")); + expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + }); + + it("dismisses with ancestor scroll", async () => { + render(Dismiss, { ancestorScroll: false }); + await fireEvent.scroll(window); + expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + }); + + it("does not dismiss when clicking portaled children", async () => { + render(DismissPortaledChildren); + await sleep(100); + + await fireEvent.pointerDown(screen.getByTestId("portaled-button"), { + bubbles: true, + }); + + await waitFor(() => + 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(), + ); + }); + + 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(); + }); + }); +}); + +/** + * 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 } = + 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 () => { + render(DismissCaptureDialogsMulti, { outsidePress: [false, false] }); + + expect(screen.getByText("outer")).toBeInTheDocument(); + expect(screen.getByText("inner")).toBeInTheDocument(); + + await sleep(30); + + await userEvent.click(screen.getByText("outer")); + expect(screen.getByText("outer")).toBeInTheDocument(); + expect(screen.getByText("inner")).toBeInTheDocument(); + await userEvent.click(screen.getByText("outside")); + + expect(screen.getByText("outer")).toBeInTheDocument(); + expect(screen.getByText("inner")).toBeInTheDocument(); + }); + }); +}); + +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(); + }); +}); diff --git a/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts b/packages/floating-ui-svelte/test/unit/hooks/use-floating.test.svelte.ts similarity index 80% rename from packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-floating.test.svelte.ts index 70175865..b959fc91 100644 --- a/packages/floating-ui-svelte/test/hooks/use-floating.svelte.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-floating.test.svelte.ts @@ -6,11 +6,14 @@ 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"; - -function createElements(): { reference: HTMLElement; floating: HTMLElement } { +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; + floating: HTMLElement | null; +} { const reference = document.createElement("div"); const floating = document.createElement("div"); reference.id = useId(); @@ -19,66 +22,67 @@ 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); + expect(floating.reference).toEqual(elements.reference); + expect(floating.floating).toEqual(elements.floating); - elements = createElements(); + 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 +92,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 +118,7 @@ describe("useFloating", () => { withRunes(async () => { const transform = true; const floating = useFloating({ - elements: createElements(), + ...createElements(), transform, }); @@ -124,7 +133,7 @@ describe("useFloating", () => { 'defaults to "true"', withRunes(async () => { const floating = useFloating({ - elements: createElements(), + ...createElements(), }); await vi.waitFor(() => { expect(floating.floatingStyles).contain( @@ -139,10 +148,8 @@ describe("useFloating", () => { let transform = $state(true); const floating = useFloating({ - elements: createElements(), - get transform() { - return transform; - }, + ...createElements(), + transform: () => transform, }); await vi.waitFor(() => { @@ -198,10 +205,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 +256,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 +278,7 @@ describe("useFloating", () => { const middleware: Array = [offset(5)]; const floating = useFloating({ - elements: createElements(), + ...createElements(), middleware, }); await vi.waitFor(() => { @@ -290,10 +293,8 @@ describe("useFloating", () => { const middleware: Array = $state([]); const floating = useFloating({ - elements: createElements(), - get middleware() { - return middleware; - }, + ...createElements(), + middleware: () => middleware, }); await vi.waitFor(() => { @@ -316,28 +317,28 @@ describe("useFloating", () => { "can be set", withRunes(() => { const floating = useFloating({ open: true }); - expect(floating.open).toBe(true); + expect(floating.context.open).toBe(true); }), ); it( "is returned", withRunes(() => { const floating = useFloating(); - expect(floating).toHaveProperty("open"); + expect(floating.context).toHaveProperty("open"); }), ); it( "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( @@ -346,18 +347,16 @@ describe("useFloating", () => { let open = $state(false); const floating = useFloating({ - elements: createElements(), - get open() { - return open; - }, + ...createElements(), + open: () => open, }); - 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); }); }), ); @@ -370,7 +369,7 @@ describe("useFloating", () => { const whileElementsMounted = vi.fn(); useFloating({ - elements: createElements(), + ...createElements(), whileElementsMounted, }); @@ -385,7 +384,6 @@ describe("useFloating", () => { const whileElementsMounted = vi.fn(); useFloating({ - elements: undefined, whileElementsMounted, }); @@ -401,7 +399,7 @@ describe("useFloating", () => { const whileElementsMounted = vi.fn(() => cleanup); const floating = useFloating({ - elements: createElements(), + ...createElements(), whileElementsMounted, }); @@ -409,8 +407,8 @@ describe("useFloating", () => { expect(whileElementsMounted).toHaveBeenCalled(); }); - floating.elements.reference = undefined; - floating.elements.floating = undefined; + floating.reference = null; + floating.floating = null; await vi.waitFor(() => { expect(cleanup).toHaveBeenCalled(); @@ -424,7 +422,7 @@ describe("useFloating", () => { const elements = createElements(); const floating = useFloating({ - elements, + ...elements, whileElementsMounted, }); @@ -446,7 +444,7 @@ describe("useFloating", () => { const onOpenChange = vi.fn(); useFloating({ - elements: createElements(), + ...createElements(), onOpenChange, }); @@ -486,11 +484,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 +530,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 +576,8 @@ describe("useFloating", () => { const middleware: Array = $state([]); const floating = useFloating({ - elements: createElements(), - get middleware() { - return middleware; - }, + ...createElements(), + middleware: () => middleware, }); await vi.waitFor(() => { @@ -631,10 +623,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 +642,8 @@ describe("useFloating", () => { let open = $state(true); const floating = useFloating({ - elements: createElements(), - get open() { - return open; - }, + ...createElements(), + open: () => open, }); await vi.waitFor(() => { @@ -735,10 +723,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 +740,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 +759,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 +778,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 +798,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 +818,8 @@ describe("useFloating", () => { let open = $state(true); const floating = useFloating({ - elements: createElements(), - get open() { - return open; - }, + ...createElements(), + open: () => open, }); await vi.waitFor(() => { @@ -864,10 +840,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 +864,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 +883,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-focus.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.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.ts b/packages/floating-ui-svelte/test/unit/hooks/use-hover.test.ts similarity index 95% rename from packages/floating-ui-svelte/test/hooks/use-hover.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-hover.test.ts index 1869f289..48f4bf25 100644 --- a/packages/floating-ui-svelte/test/hooks/use-hover.ts +++ b/packages/floating-ui-svelte/test/unit/hooks/use-hover.test.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/use-id.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.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.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.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.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.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/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..d390ec88 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/use-list-navigation.test.ts @@ -0,0 +1,1196 @@ +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 { 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"; +import EmojiPicker from "../../visual/components/emoji-picker/main.svelte"; +import ListboxFocus from "../../visual/components/listbox-focus/main.svelte"; +import NestedMenu from "../../visual/components/menu/main.svelte"; +import VirtualNested from "../../visual/components/menu-virtual/virtual-nested.svelte"; +import HomeEndIgnore from "./wrapper-components/use-list-navigation/home-end-ignore.svelte"; + +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(); + await waitFor(() => + 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", + ), + ); + }); + + it("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("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("async selectedIndex", async () => { + render(Select); + + await userEvent.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("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("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("selectedIndex changing does not steal focus", async () => { + render(ListboxFocus); + + await userEvent.click(screen.getByTestId("reference")); + await waitFor(() => expect(screen.getByTestId("reference")).toHaveFocus()); +}); + +it("focus management in nested lists", async () => { + render(NestedMenu); + await userEvent.click(screen.getByRole("button", { name: "Edit" })); + await sleep(20); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_RIGHT); + + await waitFor(() => expect(screen.getByText("Text")).toHaveFocus()); +}); + +it("virtual nested home or end key presses", async () => { + render(VirtualNested); + + await act(() => { + screen.getByRole("combobox").focus(); + }); + + await userEvent.keyboard(testKbd.ARROW_DOWN); // open menu + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); // focus Copy as menu + await userEvent.keyboard(testKbd.ARROW_RIGHT); // open Copy as submenu + await userEvent.keyboard(testKbd.END); + + expect(screen.getByText("Audio")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("Share")).not.toHaveAttribute( + "aria-selected", + "true", + ); +}); + +it("domReference trigger in nested virtual menu is set as virtual item", async () => { + render(VirtualNested); + + await act(() => { + screen.getByRole("combobox").focus(); + }); + + await userEvent.keyboard(testKbd.ARROW_DOWN); // open menu + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.ARROW_DOWN); // focus Copy as menu + await userEvent.keyboard(testKbd.ARROW_RIGHT); // open Copy as submenu + + expect(screen.getByText("Text")).toHaveAttribute("aria-selected", "true"); + + await userEvent.keyboard(testKbd.ARROW_LEFT); // close Copy as submenu + + expect(screen.getByTestId("value")).toHaveTextContent("copy"); +}); + +it("Home or End key press is ignored for typeable combobox reference", async () => { + render(HomeEndIgnore); + await userEvent.click(screen.getByRole("combobox")); + + await userEvent.keyboard(testKbd.ARROW_DOWN); + await sleep(20); + + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + await userEvent.keyboard(testKbd.END); + + await waitFor(() => expect(screen.getByTestId("item-0")).toHaveFocus()); + + await userEvent.keyboard(testKbd.ARROW_DOWN); + await userEvent.keyboard(testKbd.HOME); + + await waitFor(() => expect(screen.getByTestId("item-1")).toHaveFocus()); +}); diff --git a/packages/floating-ui-svelte/test/hooks/use-role.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.ts rename to packages/floating-ui-svelte/test/unit/hooks/use-role.test.ts 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..6ab6dc6a --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/use-typeahead.test.ts @@ -0,0 +1,200 @@ +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 Select from "./wrapper-components/use-typeahead/typeahead-select.svelte"; +import { userEvent } from "@testing-library/user-event"; +import { sleep, testKbd } from "../../utils.js"; +import Menu from "../../visual/components/menu/main.svelte"; + +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); +}); + +it("Menu - skips disabled items and opens submenu on space if no match", async () => { + vi.useRealTimers(); + + render(Menu); + + await userEvent.click(screen.getByText("Edit")); + await act(async () => {}); + expect(screen.getByRole("menu")).toBeInTheDocument(); + + await userEvent.keyboard("copy as "); + + expect(screen.getByText("Copy as").getAttribute("aria-expanded")).toBe( + "false", + ); + + await sleep(750); + await userEvent.keyboard(" "); + + await waitFor(() => + expect(screen.getByText("Copy as").getAttribute("aria-expanded")).toBe( + "true", + ), + ); +}); + +it("Menu - resets once a match is no longer found", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + render(Menu); + + await userEvent.click(screen.getByText("Edit")); + + expect(screen.getByRole("menu")).toBeInTheDocument(); + + await userEvent.keyboard("undr"); + + expect(screen.getByText("Undo")).toHaveFocus(); + + await userEvent.keyboard("r"); + + expect(screen.getByText("Redo")).toHaveFocus(); +}); + +it("typing spaces on
references does not open the menu", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const spy = vi.fn(); + render(Select, { onMatch: spy }); + + await userEvent.click(screen.getByRole("combobox")); + + await userEvent.keyboard("h"); + await userEvent.keyboard(" "); + + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + + vi.advanceTimersByTime(750); + + await userEvent.keyboard(" "); + await act(async () => {}); + + expect(screen.queryByRole("listbox")).toBeInTheDocument(); +}); 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 new file mode 100644 index 00000000..7ac5c6cb --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-click.svelte @@ -0,0 +1,46 @@ + + + +{#if open} +
+
+{/if} 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 new file mode 100644 index 00000000..1bf5f1c1 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-client-point.svelte @@ -0,0 +1,68 @@ + + + + +
+ Reference +
+{#if open} +
+ Floating +
+{/if} +{rect?.x} +{rect?.y} +{rect?.width} +{rect?.height} + 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 new file mode 100644 index 00000000..0901e158 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss.svelte @@ -0,0 +1,42 @@ + + + + +{#if open} +
+
+{/if} 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..6b931b13 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-capture-dialog.svelte @@ -0,0 +1,47 @@ + + + + + {#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-dialog.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-dialog.svelte new file mode 100644 index 00000000..6d7268fc --- /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..1895950f --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-nested.svelte @@ -0,0 +1,41 @@ + + + + + + + diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-popovers.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-popovers.svelte new file mode 100644 index 00000000..c0f1c614 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-nested-popovers.svelte @@ -0,0 +1,9 @@ + + + + + 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..13a380db --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-overlay.svelte @@ -0,0 +1,21 @@ + + + +
{ + 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-popover.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte new file mode 100644 index 00000000..b2d3b0c6 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-popover.svelte @@ -0,0 +1,48 @@ + + +{#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..d6d73678 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-portaled-children.svelte @@ -0,0 +1,27 @@ + + + +{#if open} +
+ + + +
+{/if} 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..7e7d9a66 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss-without-floating-tree.svelte @@ -0,0 +1,50 @@ + + + +{#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 new file mode 100644 index 00000000..755e5f9e --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/dismiss.svelte @@ -0,0 +1,46 @@ + + + + +{#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..85f6e63d --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-dismiss/third-party.svelte @@ -0,0 +1,30 @@ + + + +{#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 new file mode 100644 index 00000000..63f22441 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-focus.svelte @@ -0,0 +1,42 @@ + + + + +{#if open} +
+
+{/if} 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 new file mode 100644 index 00000000..655f095a --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-hover.svelte @@ -0,0 +1,55 @@ + + + + +{#if showReference} + +{/if} + +{#if open} +
+
+{/if} 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..bccb3840 --- /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.elements.domReference?.focus(); + }, + })}> + {item} +
  • + {/each} +
+
+{/if} +
{activeIndex}
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 new file mode 100644 index 00000000..35593069 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/home-end-ignore.svelte @@ -0,0 +1,52 @@ + + + + +{#if open} +
+
    + {#each ["one", "two", "three"] as string, index} + + +
  • + {string} +
  • + {/each} +
+
+{/if} 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..7b5d917c --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/main.svelte @@ -0,0 +1,57 @@ + + + +{#if open} +
+
    + {#each ["one", "two", "three"] as str, index (str)} +
  • + {str} +
  • + {/each} +
+
+{/if} 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..aa49c145 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/scheduled-option.svelte @@ -0,0 +1,41 @@ + + +
+ 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..c55ab9eb --- /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..97b2c6a3 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-list-navigation/select.svelte @@ -0,0 +1,58 @@ + + + +{#if open} + +
+ + {#each options as option (option)} + + {/each} + +
+
+{/if} diff --git a/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-role.svelte b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-role.svelte new file mode 100644 index 00000000..cd412e24 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-role.svelte @@ -0,0 +1,47 @@ + + + + +{#if open} +
+ {#each [1, 2, 3] as i} +
+
+ {/each} +
+{/if} 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..c3079659 --- /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, + useInteractions, + useTypeahead, + type UseTypeaheadOptions, +} from "../../../../../src/index.js"; +import type { Getter } from "../../../../../src/types.js"; +import { useFloating } from "../../../../../src/hooks/use-floating.svelte.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..3d25145c --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-combobox.svelte @@ -0,0 +1,16 @@ + + + +
+
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..3db1300a --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-full.svelte @@ -0,0 +1,31 @@ + + +
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..d3e7387f --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/hooks/wrapper-components/use-typeahead/typeahead-select.svelte @@ -0,0 +1,29 @@ + + + +
+
+{#if open} +
+
+{/if} diff --git a/packages/floating-ui-svelte/test/unit/internal/get-ancestors.test.ts b/packages/floating-ui-svelte/test/unit/internal/get-ancestors.test.ts new file mode 100644 index 00000000..f7a0f019 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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/unit/internal/get-children.test.ts b/packages/floating-ui-svelte/test/unit/internal/get-children.test.ts new file mode 100644 index 00000000..b5388454 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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/unit/internal/mark-others.test.ts b/packages/floating-ui-svelte/test/unit/internal/mark-others.test.ts new file mode 100644 index 00000000..29c4f696 --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/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); +}); diff --git a/packages/floating-ui-svelte/test/unit/setup.ts b/packages/floating-ui-svelte/test/unit/setup.ts new file mode 100644 index 00000000..8bc2e4bb --- /dev/null +++ b/packages/floating-ui-svelte/test/unit/setup.ts @@ -0,0 +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/utils.d.ts b/packages/floating-ui-svelte/test/utils.d.ts new file mode 100644 index 00000000..c503768a --- /dev/null +++ b/packages/floating-ui-svelte/test/utils.d.ts @@ -0,0 +1,49 @@ +declare function sleep(ms?: number): Promise; +/** + * 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. + */ +declare const testKbd: { + SHIFT_TAB: string; + k: string; + a: string; + p: string; + F1: string; + F10: string; + F11: string; + F12: string; + F2: string; + F3: string; + F4: string; + F5: string; + F6: string; + F7: string; + F8: string; + F9: string; + P: string; + A: string; + n: string; + j: string; + ALT: string; + ARROW_DOWN: string; + ARROW_LEFT: string; + ARROW_RIGHT: string; + ARROW_UP: string; + BACKSPACE: string; + CAPS_LOCK: string; + CONTROL: string; + DELETE: string; + END: string; + ENTER: string; + ESCAPE: string; + HOME: string; + META: string; + PAGE_DOWN: string; + PAGE_UP: string; + SHIFT: string; + SPACE: string; + TAB: string; + CTRL: string; + ASTERISK: string; +}; +export { testKbd, sleep }; diff --git a/packages/floating-ui-svelte/test/utils.ts b/packages/floating-ui-svelte/test/utils.ts new file mode 100644 index 00000000..17f8e22b --- /dev/null +++ b/packages/floating-ui-svelte/test/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/packages/floating-ui-svelte/test/visual/app.css b/packages/floating-ui-svelte/test/visual/app.css new file mode 100644 index 00000000..963a6edc --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/app.css @@ -0,0 +1,13 @@ +@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..52f9bceb --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/arrow/demo.svelte @@ -0,0 +1,81 @@ + + +
+ + {f.placement} + + {#if open} +
+ {#if children} + {@render children?.()} + {:else} + Tooltip + {/if} + +
+ {/if} +
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..218d6cce --- /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..1b3e9390 --- /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.elements.domReference?.focus(); + }, + })} + active={activeIndex === index}> + {item} + + {/each} +
+
+
+ {/if} +
diff --git a/packages/floating-ui-svelte/test/visual/components/button.svelte b/packages/floating-ui-svelte/test/visual/components/button.svelte new file mode 100644 index 00000000..a6addfdc --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/button.svelte @@ -0,0 +1,22 @@ + + + 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..4068f09b --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/complex-grid/main.svelte @@ -0,0 +1,113 @@ + + +

Complex Grid

+
+ + {#if open} + +
+ {#each { length: 37 } as _, index (index)} + + {/each} +
+
+ {/if} +
diff --git a/packages/floating-ui-svelte/test/visual/components/drawer/drawer.svelte b/packages/floating-ui-svelte/test/visual/components/drawer/drawer.svelte new file mode 100644 index 00000000..7dc34ee8 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/drawer/drawer.svelte @@ -0,0 +1,96 @@ + + +{#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/visual/components/drawer/main.svelte b/packages/floating-ui-svelte/test/visual/components/drawer/main.svelte new file mode 100644 index 00000000..38cb823d --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/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/visual/components/emoji-picker/main.svelte b/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte new file mode 100644 index 00000000..ad917d99 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/emoji-picker/main.svelte @@ -0,0 +1,236 @@ + + + + +

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 new file mode 100644 index 00000000..43ee578e --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/emoji-picker/option.svelte @@ -0,0 +1,43 @@ + + + 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..99273706 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/grid/main.svelte @@ -0,0 +1,78 @@ + + +

Grid

+
+ + {#if open} + + + + {/if} +
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..1e8e7f30 --- /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"; + +export 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..470b75da --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/listbox-focus/listbox.svelte @@ -0,0 +1,71 @@ + + + +
+ + {@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/main.svelte b/packages/floating-ui-svelte/test/visual/components/menu-virtual/main.svelte new file mode 100644 index 00000000..d20baee9 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu-virtual/main.svelte @@ -0,0 +1,32 @@ + + +

Menu Virtual

+
+ + console.log("Undo")} /> + + + + + + + + + + + + + + + + + + +
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 new file mode 100644 index 00000000..8f71ab18 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-impl.svelte @@ -0,0 +1,283 @@ + + + + + + {#if isNested} + + + {:else} + + + {/if} + { + hasFocusInside = v; + }, + get allowHover() { + return allowHover; + }, + get open() { + return open; + }, + set open(v: boolean) { + open = v; + }, + parent, + }}> + + {#if open} + + +
+ {@render children?.()} +
+
+
+ {/if} +
+
+
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 new file mode 100644 index 00000000..4cde32e3 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu-item.svelte @@ -0,0 +1,91 @@ + + + + +
+ {label} +
diff --git a/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu.svelte b/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu.svelte new file mode 100644 index 00000000..e7bcde61 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu-virtual/menu.svelte @@ -0,0 +1,23 @@ + + +{#if parentId === null} + + + +{:else} + +{/if} 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/menu/main.svelte b/packages/floating-ui-svelte/test/visual/components/menu/main.svelte new file mode 100644 index 00000000..06869cc9 --- /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..867c5e73 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu-impl.svelte @@ -0,0 +1,284 @@ + + + + + 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..d4647894 --- /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..dda1b7d3 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menu/menu.svelte @@ -0,0 +1,29 @@ + + +{#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/components/menubar/main.svelte b/packages/floating-ui-svelte/test/visual/components/menubar/main.svelte new file mode 100644 index 00000000..8616d473 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/menubar/main.svelte @@ -0,0 +1,69 @@ + + +

Menubar

+
+ + {#each { length: 9 } as _, i (i)} + + Item {i + 1} + + {/each} + +
+
+ + + File + + + {#snippet render({ children, ...props }, ref)} +
+ {@render children?.()} +
+ {/snippet} + View +
+ + {#snippet render(props, ref)} + + + + + + + + + + + + {/snippet} + + + {#snippet render(props, ref)} + + {/snippet} + Align + +
+
diff --git a/packages/floating-ui-svelte/test/visual/components/navigation/main.svelte b/packages/floating-ui-svelte/test/visual/components/navigation/main.svelte new file mode 100644 index 00000000..aa345fbc --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/navigation/main.svelte @@ -0,0 +1,19 @@ + + +

Navigation

+
+ + + + + + + + + +
diff --git a/packages/floating-ui-svelte/test/visual/components/navigation/navigation-item.svelte b/packages/floating-ui-svelte/test/visual/components/navigation/navigation-item.svelte new file mode 100644 index 00000000..8475de06 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/navigation/navigation-item.svelte @@ -0,0 +1,98 @@ + + + +
  • + + {label} + {#if hasChildren} + + {/if} + +
  • + {#if open} + + +
    + +
      {@render children?.()}
    +
    +
    +
    + {/if} +
    diff --git a/packages/floating-ui-svelte/test/visual/components/navigation/navigation-sub-item.svelte b/packages/floating-ui-svelte/test/visual/components/navigation/navigation-sub-item.svelte new file mode 100644 index 00000000..eff06244 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/navigation/navigation-sub-item.svelte @@ -0,0 +1,16 @@ + + + + {label} + diff --git a/packages/floating-ui-svelte/test/visual/components/navigation/navigation.svelte b/packages/floating-ui-svelte/test/visual/components/navigation/navigation.svelte new file mode 100644 index 00000000..89ec72f6 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/navigation/navigation.svelte @@ -0,0 +1,9 @@ + + + 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..73e68e6a --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/new/main.svelte @@ -0,0 +1,17 @@ + + + + + + + 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..1ab5c3cf --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/omnibox/main.svelte @@ -0,0 +1,171 @@ + + +

    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.elements.domReference + ) { + return; + } + open = false; + isFocusEnabled = false; + const domRef = f.elements + .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/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-impl.svelte b/packages/floating-ui-svelte/test/visual/components/popover/popover-impl.svelte new file mode 100644 index 00000000..c6f0cde0 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/popover/popover-impl.svelte @@ -0,0 +1,108 @@ + + + + + + {@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 new file mode 100644 index 00000000..380ede00 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/popover/popover.svelte @@ -0,0 +1,19 @@ + + + +{#if parentId === null} + + + +{:else} + +{/if} 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..bc72ade8 --- /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/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 new file mode 100644 index 00000000..ff08e869 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/components/tooltip/tooltip.svelte @@ -0,0 +1,118 @@ + + +{@render children?.(reference, ints.getReferenceProps())} + +{#if transitions.isMounted} + +
    +
    + {label} +
    +
    +
    +{/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; 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/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 @@ + + +
    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/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/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 @@ + + +
    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 @@ + + +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/menu-virtual/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/menu-virtual/+page.svelte new file mode 100644 index 00000000..15744f45 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/menu-virtual/+page.svelte @@ -0,0 +1,5 @@ + + +
    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 @@ + + +
    diff --git a/packages/floating-ui-svelte/test/visual/routes/menubar/+page.svelte b/packages/floating-ui-svelte/test/visual/routes/menubar/+page.svelte new file mode 100644 index 00000000..bee7c8ec --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/menubar/+page.svelte @@ -0,0 +1,5 @@ + + +
    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..ea735089 --- /dev/null +++ b/packages/floating-ui-svelte/test/visual/routes/new/+page.svelte @@ -0,0 +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 @@ + + +
    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/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 @@ + + +
    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 @@ + + +
    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/test/internal/with-runes.svelte.ts b/packages/floating-ui-svelte/test/with-runes.svelte.ts similarity index 100% rename from packages/floating-ui-svelte/test/internal/with-runes.svelte.ts rename to packages/floating-ui-svelte/test/with-runes.svelte.ts 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 2860c75a..641212f6 100644 --- a/packages/floating-ui-svelte/vite.config.ts +++ b/packages/floating-ui-svelte/vite.config.ts @@ -1,12 +1,21 @@ -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({ - plugins: [svelte(), svelteTesting()], + server: { + port: 1234, + fs: { + strict: false, + }, + }, + root: "./test/visual", + plugins: [sveltekit(), tailwindcss(), svelteTesting()], test: { - include: ["./test/{hooks, components}/*.ts"], - setupFiles: ["./test/internal/setup.ts"], + root: "./test/unit", + include: ["./**/*.test.ts"], + setupFiles: ["./test/unit/setup.ts"], environment: "jsdom", }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a19e8ee2..2b449628 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,40 +23,67 @@ importers: '@floating-ui/utils': specifier: ^0.2.8 version: 0.2.8 + esm-env: + specifier: ^1.2.1 + version: 1.2.1 + 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 - 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.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.16.0)(typescript@5.7.2) + 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.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.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.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.16.0)(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.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) + 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 svelte: - specifier: ^5.16.0 - version: 5.16.0 + 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) + 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) + version: 2.1.8(@types/node@22.10.5)(jsdom@25.0.1)(lightningcss@1.29.1) sites/floating-ui-svelte.vercel.app: devDependencies: @@ -65,22 +92,25 @@ 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@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.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@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.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@2.4.2)(lightningcss@1.29.1)(yaml@2.7.0)) 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 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 @@ -88,11 +118,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 @@ -101,10 +131,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: @@ -536,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'} @@ -772,6 +805,87 @@ 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==} + + '@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'} @@ -966,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==} @@ -1130,6 +1250,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'} @@ -1171,6 +1296,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'} @@ -1370,6 +1499,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.4: + resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -1418,6 +1550,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==} @@ -1441,6 +1577,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'} @@ -1798,6 +1998,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'} @@ -1826,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'} @@ -1917,6 +2130,9 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + style-to-object@1.0.8: + resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} + sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -1938,14 +2154,20 @@ 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: 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==} + svelte@5.17.3: + resolution: {integrity: sha512-eLgtpR2JiTgeuNQRCDcLx35Z7Lu9Qe09GPOz+gvtR9nmIZu5xgFd6oFiLGQlxLD0/u7xVyF5AUkjDVyFHe6Bvw==} engines: {node: '>=18'} symbol-tree@3.2.4: @@ -1959,6 +2181,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'} @@ -2089,8 +2318,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: @@ -2160,6 +2389,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: @@ -2629,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 @@ -2812,9 +3085,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@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.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@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: @@ -2822,9 +3095,9 @@ 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))': + '@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.16.0)(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 @@ -2836,43 +3109,148 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.7.1 sirv: 3.0.0 - svelte: 5.16.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.16.0)(typescript@5.7.2)': + '@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 kleur: 4.1.5 sade: 1.8.1 semver: 7.6.3 - svelte: 5.16.0 - svelte2tsx: 0.7.31(svelte@5.16.0)(typescript@5.7.2) + svelte: 5.17.3 + svelte2tsx: 0.7.31(svelte@5.17.3)(typescript@5.7.2) 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))': + '@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@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@2.4.2)(lightningcss@1.29.1)(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.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.16.0)(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.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.29.1)(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) + 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.16.0)(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.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/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.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)) + 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) + 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 + '@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 + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@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.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.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: '@babel/code-frame': 7.26.2 @@ -2894,13 +3272,13 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/svelte@5.2.6(svelte@5.16.0)(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.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.16.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.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)': dependencies: @@ -2957,13 +3335,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.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) + vite: 5.4.8(@types/node@22.10.5)(lightningcss@1.29.1) '@vitest/pretty-format@2.1.8': dependencies: @@ -3070,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 @@ -3212,6 +3600,8 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@1.0.3: {} + detect-libc@2.0.3: {} devalue@5.1.1: {} @@ -3242,6 +3632,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 @@ -3507,6 +3902,8 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.4: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -3548,6 +3945,8 @@ snapshots: jiti@1.21.7: {} + jiti@2.4.2: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -3590,6 +3989,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: {} @@ -3612,9 +4056,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: {} @@ -3899,6 +4343,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 + resize-observer-polyfill@1.5.1: {} + resolve-from@5.0.0: {} resolve@1.22.10: @@ -3945,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 @@ -4033,6 +4489,10 @@ snapshots: dependencies: min-indent: 1.0.1 + style-to-object@1.0.8: + dependencies: + inline-style-parser: 0.2.4 + sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.8 @@ -4049,26 +4509,33 @@ 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 - svelte2tsx@0.7.31(svelte@5.16.0)(typescript@5.7.2): + 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 pascal-case: 3.1.2 - svelte: 5.16.0 + svelte: 5.17.3 typescript: 5.7.2 - svelte@5.16.0: + svelte@5.17.3: dependencies: '@ampproject/remapping': 2.3.0 '@jridgewell/sourcemap-codec': 1.5.0 @@ -4117,6 +4584,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 @@ -4237,13 +4708,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.8(@types/node@22.10.5)(lightningcss@1.29.1) transitivePeerDependencies: - '@types/node' - less @@ -4255,13 +4726,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.8(@types/node@22.10.5)(lightningcss@1.29.1): dependencies: esbuild: 0.21.5 postcss: 8.4.49 @@ -4269,8 +4740,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 @@ -4278,17 +4750,34 @@ 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 + + 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@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@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.6(@types/node@22.10.5)(jiti@1.21.7)(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): + 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.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 @@ -4304,8 +4793,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.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: '@types/node': 22.10.5 diff --git a/sites/floating-ui-svelte.vercel.app/package.json b/sites/floating-ui-svelte.vercel.app/package.json index 5987db4b..a63261ce 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": { @@ -14,11 +15,12 @@ "@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", "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)/+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)/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.

    +`} />
    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..3b2abe98 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,70 @@
    {#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..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 @@ -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/(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..3597a882 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/(drawer)/main.svelte @@ -0,0 +1,27 @@ + + +

    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/(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 new file mode 100644 index 00000000..f259cb10 --- /dev/null +++ b/sites/floating-ui-svelte.vercel.app/src/routes/(inner)/sink/+page.svelte @@ -0,0 +1,66 @@ + + +
    + 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/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 @@ + + + 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} + 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/vite.config.ts b/sites/floating-ui-svelte.vercel.app/vite.config.ts index 2278a279..195661bf 100644 --- a/sites/floating-ui-svelte.vercel.app/vite.config.ts +++ b/sites/floating-ui-svelte.vercel.app/vite.config.ts @@ -4,4 +4,7 @@ import pagefind from "vite-plugin-pagefind"; export default defineConfig({ plugins: [sveltekit(), pagefind()], + optimizeDeps: { + exclude: ["@skeletonlabs/floating-ui-svelte"], + }, });