diff --git a/package.json b/package.json index b79bff3..d293572 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "devDependencies": { "@eslint/compat": "^1.2.5", "@eslint/js": "^9.18.0", + "@floating-ui/dom": "^1.7.6", "@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/package": "^2.5.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03a4cf3..9d4e7e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 8.1.0 melt: specifier: ^0.37.0 - version: 0.37.0(@floating-ui/dom@1.7.3)(svelte@5.38.1) + version: 0.37.0(@floating-ui/dom@1.7.6)(svelte@5.38.1) moment: specifier: ^2.30.1 version: 2.30.1 @@ -66,6 +66,9 @@ importers: '@eslint/js': specifier: ^9.18.0 version: 9.33.0 + '@floating-ui/dom': + specifier: ^1.7.6 + version: 1.7.6 '@sveltejs/adapter-auto': specifier: ^4.0.0 version: 4.0.0(@sveltejs/kit@2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.1)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1))) @@ -328,14 +331,14 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/dom@1.7.3': - resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1803,16 +1806,16 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@floating-ui/core@1.7.3': + '@floating-ui/core@1.7.5': dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.7.3': + '@floating-ui/dom@1.7.6': dependencies: - '@floating-ui/core': 1.7.3 - '@floating-ui/utils': 0.2.10 + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 - '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.11': {} '@humanfs/core@0.19.1': {} @@ -2641,9 +2644,9 @@ snapshots: '@babel/runtime': 7.28.2 remove-accents: 0.5.0 - melt@0.37.0(@floating-ui/dom@1.7.3)(svelte@5.38.1): + melt@0.37.0(@floating-ui/dom@1.7.6)(svelte@5.38.1): dependencies: - '@floating-ui/dom': 1.7.3 + '@floating-ui/dom': 1.7.6 dequal: 2.0.3 focus-trap: 7.6.5 jest-axe: 9.0.0 diff --git a/src/lib/floating.svelte.ts b/src/lib/floating.svelte.ts new file mode 100644 index 0000000..a0833b9 --- /dev/null +++ b/src/lib/floating.svelte.ts @@ -0,0 +1,144 @@ +/** + * implements a popover using floating-ui for positioning and auto-update + * see https://www.skeleton.dev/docs/svelte/guides/cookbook/floating-ui-attachments + * for more details, examples, and original source. + */ + +import { computePosition, autoUpdate, flip, offset, type Placement } from '@floating-ui/dom'; +import { createAttachmentKey } from 'svelte/attachments'; + +/** + * Options for configuring the Popover behavior and appearance. + */ +export interface PopoverOptions { + /** Interaction type for the popover */ + interaction?: 'click' | 'hover' | 'manual'; + /** Placement of the popover */ + placement?: Placement; + /** Offset distance between the reference and floating elements (default: 8) */ + offset?: number; + /** Callback when the popover is opened or closed */ + ontoggle?: (open: boolean) => void; +} + +/** + * Popover class that manages the state and behavior of a popover element. + * It uses floating-ui for positioning and auto-update functionality. + */ +export class Popover { + private options: PopoverOptions = { + interaction: 'click', + placement: 'bottom-start', + offset: 8 + }; + private referenceElement: HTMLElement | undefined = $state(); + private floatingElement: HTMLElement | undefined = $state(); + private open = $state(false); + + /** + * Creates a new Popover instance with optional configuration options. + * @param options - Optional configuration for the popover behavior and appearance. + */ + constructor(options?: PopoverOptions) { + if (options) this.options = { ...this.options, ...options }; + $effect(() => { + if (!this.referenceElement || !this.floatingElement) return; + return autoUpdate(this.referenceElement, this.floatingElement, this.#updatePosition); + }); + } + + /** + * Generates attributes for the reference element that triggers the popover. + * Includes event handlers based on the specified interaction type (click or hover). + * @returns An object containing necessary attributes and event handlers for + * the reference element. + */ + reference() { + const attrs = { + [createAttachmentKey()]: (node: HTMLElement) => { + this.referenceElement = node; + return () => { + this.referenceElement = undefined; + }; + }, + onclick: () => {}, + onmouseover: () => {}, + onmouseout: () => {} + }; + // If click interaction + if (this.options.interaction === 'click') { + attrs['onclick'] = () => { + console.log('reference clicked, toggling popover'); + this.setOpen(!this.open); + }; + } + // If hover interaction + if (this.options.interaction === 'hover') { + attrs['onclick'] = () => { + this.setOpen(!this.open); + }; + attrs['onmouseover'] = () => { + this.setOpen(true); + }; + attrs['onmouseout'] = () => { + this.setOpen(false); + }; + } + return attrs; + } + + /** Returns whether the popover is open */ + isOpen() { + return this.open; + } + /** Sets whether the popover is open, and triggers callbacks */ + setOpen(open: boolean) { + if (this.open !== open) { + this.open = open; + this.options.ontoggle?.(open); + } + } + + /** + * Generates attributes for the floating element (popover content) that is positioned + * relative to the reference element. It includes an attachment key to link the + * floating element to the popover instance. + * @returns An object containing necessary attributes for the floating element. + */ + floating() { + return { + [createAttachmentKey()]: (node: HTMLElement) => { + this.floatingElement = node; + node.style.position = 'absolute'; + node.style.top = '0'; + node.style.left = '0'; + return () => { + this.floatingElement = undefined; + node.style.position = ''; + node.style.top = ''; + node.style.left = ''; + }; + } + }; + } + + /** + * Updates the position of the floating element based on the reference element using + * the computePosition function from floating-ui. It applies the calculated + * position to the floating element's style. + */ + #updatePosition = async () => { + if (!this.referenceElement || !this.floatingElement) { + return; + } + const position = await computePosition(this.referenceElement, this.floatingElement, { + placement: this.options.placement, + middleware: [flip(), offset(this.options.offset)] + }); + const { x, y } = position; + Object.assign(this.floatingElement.style, { + left: `${x}px`, + top: `${y}px` + }); + }; +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 9a695b3..e65a9e0 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -21,6 +21,7 @@ export { iso8601ToDuration } from './DurationInput.svelte'; export { default as ErrorBox } from './ErrorBox.svelte'; +export { type PopoverOptions, Popover } from './floating.svelte'; export { default as FramelessButton } from './FramelessButton.svelte'; export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte'; export { default as InjectUmami } from './InjectUmami.svelte';