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

View File

@@ -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",

33
pnpm-lock.yaml generated
View File

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

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