add floating-ui based popover attachment

This commit is contained in:
Elijah Duffy
2026-03-10 17:25:32 -07:00
parent f8eb05cccf
commit 9fbc6f6301
4 changed files with 164 additions and 15 deletions

144
src/lib/floating.svelte.ts Normal file
View File

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

View File

@@ -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';