add floating-ui based popover attachment
This commit is contained in:
144
src/lib/floating.svelte.ts
Normal file
144
src/lib/floating.svelte.ts
Normal 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`
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user