/** * 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` }); }; }