add floating-ui based popover attachment
This commit is contained in:
@@ -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
33
pnpm-lock.yaml
generated
@@ -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
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